api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. from __future__ import annotations
  2. import os
  3. from fastapi import FastAPI, HTTPException, Query
  4. from fastapi.exceptions import RequestValidationError
  5. from fastapi.encoders import jsonable_encoder
  6. from fastapi.middleware.cors import CORSMiddleware
  7. from fastapi.responses import JSONResponse
  8. from content_agent.dashboard_service import DashboardService
  9. from content_agent.errors import ErrorCode, error_response, sanitize_error_detail
  10. from content_agent.flow_ledger_service import FlowLedgerService
  11. from content_agent.run_service import RunService
  12. from content_agent.schemas import (
  13. ConfigFileResponse,
  14. ContentItemsResponse,
  15. DashboardResponse,
  16. FlowLedgerResponse,
  17. FlowLedgerVideoResponse,
  18. FlowLedgerVideosResponse,
  19. FlowLedgerWalkResponse,
  20. JsonFileResponse,
  21. PlatformCatalogResponse,
  22. PlatformDescriptor,
  23. QueryListResponse,
  24. RecordsResponse,
  25. RunListResponse,
  26. RunStartRequest,
  27. RunStartResponse,
  28. RunSummaryResponse,
  29. RuntimeFileResponse,
  30. RuntimeFilesResponse,
  31. TimelineResponse,
  32. ValidationResponse,
  33. )
  34. app = FastAPI(title="Content Agent V1")
  35. service = RunService.from_env()
  36. _cors_origins = [
  37. origin.strip()
  38. for origin in os.environ.get(
  39. "CONTENT_AGENT_WEB_CORS_ORIGINS",
  40. "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3010,http://127.0.0.1:3010",
  41. ).split(",")
  42. if origin.strip()
  43. ]
  44. if _cors_origins:
  45. app.add_middleware(
  46. CORSMiddleware,
  47. allow_origins=_cors_origins,
  48. allow_credentials=False,
  49. allow_methods=["GET", "POST", "OPTIONS"],
  50. allow_headers=["*"],
  51. )
  52. @app.exception_handler(RequestValidationError)
  53. async def validation_exception_handler(request, exc: RequestValidationError):
  54. return JSONResponse(
  55. status_code=422,
  56. content={
  57. "detail": error_response(
  58. ErrorCode.INVALID_REQUEST,
  59. "invalid request",
  60. {"errors": jsonable_encoder(sanitize_error_detail(exc.errors()))},
  61. )
  62. },
  63. )
  64. @app.post("/runs", response_model=RunStartResponse)
  65. def start_run(request: RunStartRequest) -> RunStartResponse:
  66. state = service.start_run(request)
  67. if state["status"] not in {"success", "partial_success"}:
  68. detail = error_response(
  69. state.get("error_code", ErrorCode.RUN_START_FAILED),
  70. state.get("error_message", "run failed"),
  71. state.get("error_detail", {"errors": state.get("errors", [])}),
  72. )
  73. raise HTTPException(status_code=state.get("http_status_code", 500), detail=detail)
  74. run_id = state["run_id"]
  75. return RunStartResponse(
  76. run_id=run_id,
  77. policy_run_id=state["policy_run_id"],
  78. status=state["status"],
  79. policy_bundle_id=state["policy_bundle_id"],
  80. strategy_version=state["strategy_version"],
  81. platform=state["platform"],
  82. platform_mode=state["platform_mode"],
  83. output_dir=str(service.runtime.run_dir(run_id)),
  84. )
  85. @app.get("/runs", response_model=RunListResponse)
  86. def list_runs(
  87. status: str | None = None,
  88. platform: str | None = None,
  89. platform_mode: str | None = None,
  90. strategy_version: str | None = None,
  91. validation_status: str | None = None,
  92. error_code: str | None = None,
  93. page: int = Query(default=1, ge=1),
  94. page_size: int = Query(default=20, ge=1, le=100),
  95. ) -> RunListResponse:
  96. return RunListResponse(
  97. **_dashboard_service().list_runs(
  98. status=status,
  99. platform=platform,
  100. platform_mode=platform_mode,
  101. strategy_version=strategy_version,
  102. validation_status=validation_status,
  103. error_code=error_code,
  104. page=page,
  105. page_size=page_size,
  106. )
  107. )
  108. @app.get("/runs/{run_id}", response_model=RunSummaryResponse)
  109. def get_run(run_id: str) -> RunSummaryResponse:
  110. _ensure_run_exists(run_id)
  111. return RunSummaryResponse(**service.get_summary(run_id))
  112. _CONFIG_FILES = {
  113. "rule-packs": "product_documents/规则包/douyin_rule_packs.v1.json",
  114. "walk-strategy": "product_documents/抖音游走策略/douyin_walk_strategy.v1.json",
  115. "query-prompts": "product_documents/配置/query_prompts.v1.json",
  116. "walk-policy": "tech_documents/数据接口与来源/walk_policy.json",
  117. }
  118. def _config_file_response(key: str) -> ConfigFileResponse:
  119. from pathlib import Path
  120. from content_agent.integrations import config_store
  121. path = Path(_CONFIG_FILES[key])
  122. if not path.exists():
  123. raise HTTPException(status_code=404, detail=error_response(
  124. ErrorCode.RUN_NOT_FOUND, f"config file missing: {key}", {"source_file": str(path)}
  125. ))
  126. data, _ = config_store.load_json(path)
  127. return ConfigFileResponse(source_file=str(path), data=data)
  128. @app.get("/config/rule-packs", response_model=ConfigFileResponse)
  129. def get_config_rule_packs() -> ConfigFileResponse:
  130. return _config_file_response("rule-packs")
  131. @app.get("/config/walk-strategy", response_model=ConfigFileResponse)
  132. def get_config_walk_strategy() -> ConfigFileResponse:
  133. return _config_file_response("walk-strategy")
  134. @app.get("/config/query-prompts", response_model=ConfigFileResponse)
  135. def get_config_query_prompts() -> ConfigFileResponse:
  136. return _config_file_response("query-prompts")
  137. @app.get("/config/walk-policy", response_model=ConfigFileResponse)
  138. def get_config_walk_policy() -> ConfigFileResponse:
  139. # 返回原始 JSON 不解包 {value,provenance,tbd}:展示层保留拍板留痕,解包由前端做。
  140. return _config_file_response("walk-policy")
  141. _PLATFORM_PROFILE_DIR = "tech_documents/数据接口与来源/platform_profiles"
  142. @app.get("/config/platforms", response_model=PlatformCatalogResponse)
  143. def get_config_platforms() -> PlatformCatalogResponse:
  144. # 平台展示目录:从 platform_profiles/*.json 派生平台 label 与可观测字段。
  145. # heat_fields 是 V3 历史展示字段;observable_fields 才是 V4 平台表现解释字段。
  146. # 前端按此渲染平台名与互动数据——加平台只改 profile JSON,前端零改动。
  147. from pathlib import Path
  148. from content_agent.integrations import config_store
  149. catalog: dict[str, PlatformDescriptor] = {}
  150. profile_dir = Path(_PLATFORM_PROFILE_DIR)
  151. for path in sorted(profile_dir.glob("*.json")):
  152. try:
  153. data, _ = config_store.load_json(path)
  154. except (FileNotFoundError, OSError, ValueError):
  155. continue
  156. platform = str(data.get("platform") or path.stem)
  157. heat_fields = [
  158. str(sig["field"])
  159. for sig in ((data.get("heat") or {}).get("signals") or [])
  160. if isinstance(sig, dict) and sig.get("field")
  161. ]
  162. observable_fields = [
  163. str(item["field"])
  164. for item in (data.get("observable_fields") or [])
  165. if isinstance(item, dict) and item.get("field")
  166. ]
  167. catalog[platform] = PlatformDescriptor(
  168. platform=platform,
  169. label=str(data.get("platform_label") or platform),
  170. status=data.get("status"),
  171. heat_fields=heat_fields,
  172. observable_fields=observable_fields,
  173. )
  174. return PlatformCatalogResponse(platforms=catalog)
  175. @app.get("/runs/{run_id}/dashboard", response_model=DashboardResponse)
  176. def get_run_dashboard(run_id: str) -> DashboardResponse:
  177. _ensure_web_run_exists(run_id)
  178. return DashboardResponse(**_dashboard_service().dashboard(run_id))
  179. @app.get("/runs/{run_id}/queries", response_model=QueryListResponse)
  180. def get_run_queries(run_id: str) -> QueryListResponse:
  181. _ensure_web_run_exists(run_id)
  182. return QueryListResponse(**_dashboard_service().queries(run_id))
  183. @app.get("/runs/{run_id}/timeline", response_model=TimelineResponse)
  184. def get_run_timeline(run_id: str) -> TimelineResponse:
  185. _ensure_web_run_exists(run_id)
  186. return TimelineResponse(**_dashboard_service().timeline(run_id))
  187. @app.get("/runs/{run_id}/content-items", response_model=ContentItemsResponse)
  188. def get_run_content_items(run_id: str) -> ContentItemsResponse:
  189. _ensure_web_run_exists(run_id)
  190. return ContentItemsResponse(**_dashboard_service().content_items(run_id))
  191. @app.get("/runs/{run_id}/flow-ledger", response_model=FlowLedgerResponse)
  192. def get_flow_ledger(run_id: str, debug: bool = False) -> FlowLedgerResponse:
  193. _ensure_web_run_exists(run_id)
  194. return FlowLedgerResponse(**_flow_ledger_service().ledger(run_id, debug=debug))
  195. @app.get("/runs/{run_id}/flow-ledger/queries/{query_id}/videos", response_model=FlowLedgerVideosResponse)
  196. def get_flow_ledger_query_videos(
  197. run_id: str,
  198. query_id: str,
  199. debug: bool = False,
  200. ) -> FlowLedgerVideosResponse:
  201. _ensure_web_run_exists(run_id)
  202. return FlowLedgerVideosResponse(
  203. **_flow_ledger_service().query_videos(run_id, query_id, debug=debug)
  204. )
  205. @app.get("/runs/{run_id}/flow-ledger/queries/{query_id}/walk", response_model=FlowLedgerWalkResponse)
  206. def get_flow_ledger_query_walk(
  207. run_id: str,
  208. query_id: str,
  209. debug: bool = False,
  210. ) -> FlowLedgerWalkResponse:
  211. _ensure_web_run_exists(run_id)
  212. return FlowLedgerWalkResponse(
  213. **_flow_ledger_service().query_walk(run_id, query_id, debug=debug)
  214. )
  215. @app.get("/runs/{run_id}/flow-ledger/videos/{content_id}", response_model=FlowLedgerVideoResponse)
  216. def get_flow_ledger_video(
  217. run_id: str,
  218. content_id: str,
  219. debug: bool = False,
  220. ) -> FlowLedgerVideoResponse:
  221. _ensure_web_run_exists(run_id)
  222. payload = _flow_ledger_service().video_detail(run_id, content_id, debug=debug)
  223. if payload.get("video") is None:
  224. raise HTTPException(
  225. status_code=404,
  226. detail=error_response(
  227. ErrorCode.RUN_NOT_FOUND,
  228. "video not found",
  229. {"run_id": run_id, "content_id": content_id},
  230. ),
  231. )
  232. return FlowLedgerVideoResponse(**payload)
  233. @app.get("/runs/{run_id}/runtime-files", response_model=RuntimeFilesResponse)
  234. def get_run_runtime_files(run_id: str) -> RuntimeFilesResponse:
  235. _ensure_web_run_exists(run_id)
  236. return RuntimeFilesResponse(**_dashboard_service().runtime_files(run_id))
  237. @app.get("/runs/{run_id}/runtime-files/{filename}", response_model=RuntimeFileResponse)
  238. def get_run_runtime_file(
  239. run_id: str,
  240. filename: str,
  241. limit: int = Query(default=100, ge=1, le=500),
  242. offset: int = Query(default=0, ge=0),
  243. ) -> RuntimeFileResponse:
  244. _ensure_web_run_exists(run_id)
  245. try:
  246. return RuntimeFileResponse(
  247. **_dashboard_service().runtime_file(
  248. run_id,
  249. filename,
  250. limit=limit,
  251. offset=offset,
  252. )
  253. )
  254. except ValueError:
  255. raise HTTPException(
  256. status_code=400,
  257. detail=error_response(
  258. ErrorCode.INVALID_REQUEST,
  259. "runtime filename is not allowed",
  260. {"filename": filename},
  261. ),
  262. )
  263. @app.get("/runs/{run_id}/discovered-content-items", response_model=RecordsResponse)
  264. def get_discovered_content_items(run_id: str) -> RecordsResponse:
  265. return _jsonl_response(run_id, "discovered_content_items.jsonl")
  266. @app.get("/runs/{run_id}/rule-decisions", response_model=RecordsResponse)
  267. def get_rule_decisions(run_id: str) -> RecordsResponse:
  268. return _jsonl_response(run_id, "rule_decisions.jsonl")
  269. @app.get("/runs/{run_id}/source-path-records", response_model=RecordsResponse)
  270. def get_source_path_records(run_id: str) -> RecordsResponse:
  271. return _jsonl_response(run_id, "source_path_records.jsonl")
  272. @app.get("/runs/{run_id}/final-output", response_model=JsonFileResponse)
  273. def get_final_output(run_id: str) -> JsonFileResponse:
  274. return _json_response(run_id, "final_output.json")
  275. @app.get("/runs/{run_id}/strategy-review", response_model=JsonFileResponse)
  276. def get_strategy_review(run_id: str) -> JsonFileResponse:
  277. _ensure_web_run_exists(run_id)
  278. return JsonFileResponse(run_id=run_id, data=service.strategy_review(run_id))
  279. @app.post("/runs/{run_id}/strategy-review/regenerate", response_model=JsonFileResponse)
  280. def regenerate_strategy_review(run_id: str) -> JsonFileResponse:
  281. _ensure_web_run_exists(run_id)
  282. return JsonFileResponse(run_id=run_id, data=service.regenerate_strategy_review(run_id))
  283. @app.get("/runs/{run_id}/validation", response_model=ValidationResponse)
  284. def get_validation(run_id: str) -> ValidationResponse:
  285. _ensure_web_run_exists(run_id)
  286. return ValidationResponse(**_dashboard_service()._validate_optional(run_id))
  287. def _jsonl_response(run_id: str, filename: str) -> RecordsResponse:
  288. _ensure_web_run_exists(run_id)
  289. return RecordsResponse(run_id=run_id, records=service.read_jsonl(run_id, filename))
  290. def _json_response(run_id: str, filename: str) -> JsonFileResponse:
  291. _ensure_web_run_exists(run_id)
  292. return JsonFileResponse(run_id=run_id, data=service.read_json(run_id, filename))
  293. def _ensure_run_exists(run_id: str) -> None:
  294. if not service.runtime.run_dir(run_id).exists():
  295. raise HTTPException(
  296. status_code=404,
  297. detail=error_response(
  298. ErrorCode.RUN_NOT_FOUND,
  299. "run not found",
  300. {"run_id": run_id},
  301. ),
  302. )
  303. def _ensure_web_run_exists(run_id: str) -> None:
  304. if not _dashboard_service().run_exists(run_id):
  305. raise HTTPException(
  306. status_code=404,
  307. detail=error_response(
  308. ErrorCode.RUN_NOT_FOUND,
  309. "run not found",
  310. {"run_id": run_id},
  311. ),
  312. )
  313. def _dashboard_service() -> DashboardService:
  314. return DashboardService.from_runtime(service.runtime)
  315. def _flow_ledger_service() -> FlowLedgerService:
  316. return FlowLedgerService(service.runtime)