api.py 16 KB

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