app.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. """FastAPI application — category tree API on port 8080."""
  2. from __future__ import annotations
  3. from contextlib import asynccontextmanager
  4. from pathlib import Path
  5. from urllib.parse import urlparse
  6. from typing import Literal
  7. from fastapi import FastAPI, HTTPException, Query, Request, status
  8. from fastapi.middleware.cors import CORSMiddleware
  9. from fastapi.staticfiles import StaticFiles
  10. from pydantic import BaseModel, Field
  11. from sqlalchemy import text
  12. from starlette.exceptions import HTTPException as StarletteHTTPException
  13. from starlette.responses import RedirectResponse
  14. from api.auth_middleware import AuthenticationMiddleware
  15. from api.routers.auth import router as auth_router
  16. from api.routers.llm_billing import router as llm_billing_router
  17. from api.routers.find_agent_v2 import router as find_agent_v2_router
  18. from api.routers.pipeline import router as pipeline_router
  19. from api.schemas.demand_feedback import CreateDemandFeedbackBody
  20. from api.services.agent_catalog import (
  21. get_agent_detail,
  22. update_agent_document_injection,
  23. )
  24. from api.services.auth import ensure_bootstrap_admin
  25. from api.services.category_tree import build_category_tree
  26. from api.services.aigc_article_html import get_latest_article_html
  27. from api.services.growth_category_tree import (
  28. build_growth_category_tree,
  29. list_growth_category_channel_contents,
  30. )
  31. from api.services.demand_belong_category import list_demand_belong_categories
  32. from api.services.demand_grade import list_demand_grades
  33. from api.services.demand_grade_videos import list_videos_for_demand_grade
  34. from api.services.demand_feedback import (
  35. FeedbackRequestConflictError,
  36. FeedbackTargetConflictError,
  37. FeedbackTargetNotFoundError,
  38. create_demand_feedback,
  39. list_demand_feedback,
  40. )
  41. from api.services.demand_videos import list_videos_for_demand_belong
  42. from api.services.oss_logs import list_agent_oss_logs, list_demand_belong_oss_logs
  43. from api.services.scheduler import (
  44. get_scheduler_job_run,
  45. list_triggerable_jobs,
  46. run_scheduler_job,
  47. run_supply_pipeline,
  48. scheduler_status as get_persistent_scheduler_status,
  49. )
  50. from api.services.video_discovery import (
  51. get_video_discovery_demand,
  52. list_video_discovery_demands,
  53. )
  54. from api.services.video_discovery_records import (
  55. get_video_discovery_run,
  56. list_video_discovery_candidates,
  57. list_video_discovery_runs,
  58. list_video_discovery_searches,
  59. )
  60. from supply_infra.config import get_infra_settings
  61. from supply_infra.db import dispose_engine, get_session, init_db
  62. class SPAStaticFiles(StaticFiles):
  63. """Serve index.html for browser routes handled by Vue Router."""
  64. async def get_response(self, path: str, scope):
  65. try:
  66. return await super().get_response(path, scope)
  67. except StarletteHTTPException as exc:
  68. is_backend_path = (
  69. path == "api"
  70. or path.startswith("api/")
  71. or path == "health"
  72. or path.startswith("health/")
  73. )
  74. if exc.status_code != 404 or is_backend_path or Path(path).suffix:
  75. raise
  76. return await super().get_response("index.html", scope)
  77. @asynccontextmanager
  78. async def lifespan(_app: FastAPI):
  79. if get_infra_settings().database_auto_create:
  80. init_db()
  81. ensure_bootstrap_admin()
  82. yield
  83. dispose_engine()
  84. app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
  85. app.include_router(auth_router)
  86. app.include_router(pipeline_router)
  87. app.include_router(llm_billing_router)
  88. app.include_router(find_agent_v2_router)
  89. app.add_middleware(
  90. CORSMiddleware,
  91. allow_origins=[
  92. "http://localhost:5173",
  93. "http://127.0.0.1:5173",
  94. "http://localhost:4173",
  95. "http://127.0.0.1:4173",
  96. ],
  97. allow_credentials=True,
  98. allow_methods=["*"],
  99. allow_headers=["*"],
  100. )
  101. app.add_middleware(AuthenticationMiddleware)
  102. @app.get("/health")
  103. def health() -> dict[str, str]:
  104. return {"status": "ok"}
  105. @app.get("/health/live")
  106. def health_live() -> dict[str, str]:
  107. return {"status": "ok"}
  108. @app.get("/health/ready")
  109. def health_ready() -> dict[str, str]:
  110. try:
  111. with get_session() as session:
  112. session.execute(text("SELECT 1"))
  113. except Exception as exc:
  114. raise HTTPException(status_code=503, detail="database unavailable") from exc
  115. return {"status": "ready"}
  116. @app.get("/api/scheduler/status")
  117. def scheduler_status() -> dict:
  118. """Deprecated compatibility view backed by MySQL pipeline state."""
  119. return get_persistent_scheduler_status()
  120. class RunPipelineBody(BaseModel):
  121. biz_dt: str | None = Field(
  122. default=None,
  123. pattern=r"^\d{8}$",
  124. description="业务日 YYYYMMDD;省略则取当天",
  125. )
  126. class TriggerSchedulerJobBody(BaseModel):
  127. biz_dt: str | None = Field(
  128. default=None,
  129. pattern=r"^\d{8}$",
  130. description="业务日 YYYYMMDD;省略则取当天",
  131. )
  132. class AgentDocumentInjectionBody(BaseModel):
  133. content: str = Field(
  134. default="",
  135. max_length=100_000,
  136. description="追加到 Agent System Prompt 后的业务文档",
  137. )
  138. enabled: bool = Field(
  139. default=True,
  140. description="是否在新建 Agent 时启用该文档注入",
  141. )
  142. @app.post("/api/pipeline/run", status_code=202)
  143. def run_pipeline(
  144. body: RunPipelineBody | None = None,
  145. biz_dt: str | None = Query(
  146. default=None,
  147. pattern=r"^\d{8}$",
  148. description="业务日 YYYYMMDD(与 body 二选一,body 优先)",
  149. ),
  150. ) -> dict:
  151. """
  152. 异步一键执行供给数据全流程,立即返回 run_id,后台串行执行:
  153. 全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展 → find_agent 找视频 → AIGC 发布。
  154. """
  155. resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
  156. return run_supply_pipeline(biz_dt=resolved_biz_dt)
  157. @app.get("/api/scheduler/jobs")
  158. def scheduler_jobs() -> dict:
  159. """Return jobs that can be triggered manually."""
  160. return {"items": list_triggerable_jobs()}
  161. @app.post("/api/scheduler/jobs/{job_id}/run")
  162. def trigger_scheduler_job(
  163. job_id: str,
  164. body: TriggerSchedulerJobBody | None = None,
  165. biz_dt: str | None = Query(
  166. default=None,
  167. pattern=r"^\d{8}$",
  168. description="业务日 YYYYMMDD(与 body 二选一,body 优先)",
  169. ),
  170. ) -> dict:
  171. """Manually trigger a scheduler job."""
  172. resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
  173. try:
  174. return run_scheduler_job(job_id, biz_dt=resolved_biz_dt)
  175. except KeyError:
  176. raise HTTPException(status_code=404, detail=f"scheduler job not found: {job_id}") from None
  177. @app.get("/api/scheduler/runs/{run_id}")
  178. def scheduler_job_run(run_id: str) -> dict:
  179. """Return durable pipeline run status."""
  180. result = get_scheduler_job_run(run_id)
  181. if result is None:
  182. raise HTTPException(status_code=404, detail="scheduler run not found")
  183. return result
  184. @app.get("/api/category-tree")
  185. def category_tree(
  186. biz_dt: str | None = Query(
  187. default=None,
  188. description="业务日 YYYYMMDD;省略则取 category_tree_weight 最新一日",
  189. ),
  190. ) -> dict:
  191. """Return nested global_tree_category with per-dim avg score."""
  192. return build_category_tree(biz_dt=biz_dt)
  193. @app.get("/api/growth-category-tree")
  194. def growth_category_tree(
  195. biz_dt: str | None = Query(
  196. default=None,
  197. pattern=r"^\d{8}$",
  198. description="业务日 YYYYMMDD;省略则取最新一个全部节点计算完成的日期",
  199. ),
  200. ) -> dict:
  201. """Return the V2 category tree with three growth heat dimensions."""
  202. return build_growth_category_tree(biz_dt=biz_dt)
  203. @app.get("/api/growth-category-tree/{stable_id}/channel-contents")
  204. def growth_category_channel_contents(
  205. stable_id: int,
  206. metric: Literal["read_rate", "avg_read_rate", "like_rate"] = Query(...),
  207. biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
  208. ) -> dict:
  209. """Return the selected metric's daily Top 10 contents for one node."""
  210. return list_growth_category_channel_contents(
  211. stable_id=stable_id,
  212. metric=metric,
  213. biz_dt=biz_dt,
  214. )
  215. @app.get("/api/growth-channel-content/{channel_content_id}/view")
  216. def growth_channel_content_view(channel_content_id: str) -> RedirectResponse:
  217. """Redirect to the latest external visualization page for one content."""
  218. target = get_latest_article_html(channel_content_id)
  219. if target is None:
  220. raise HTTPException(status_code=404, detail="article visualization page not found")
  221. target = target.strip()
  222. parsed = urlparse(target)
  223. if parsed.scheme not in {"http", "https"} or not parsed.netloc:
  224. raise HTTPException(status_code=422, detail="article visualization target is not a URL")
  225. return RedirectResponse(url=target, status_code=302)
  226. @app.get("/api/demand-belong-category")
  227. def demand_belong_category() -> dict:
  228. """Return all active demand_belong_category rows in one response."""
  229. items = list_demand_belong_categories()
  230. return {"items": items}
  231. @app.get("/api/demand-belong-category/{belong_id}/videos")
  232. def demand_belong_videos(belong_id: int) -> dict:
  233. """Return videos linked to a demand_belong_category row (vid + title + points JSON)."""
  234. result = list_videos_for_demand_belong(belong_id)
  235. if result is None:
  236. raise HTTPException(status_code=404, detail="demand_belong_category not found")
  237. return result
  238. @app.get("/api/demand-grade")
  239. def demand_grade(
  240. biz_dt: str | None = Query(
  241. default=None,
  242. description="业务日 YYYYMMDD;省略则取 demand_grade 最新一日",
  243. ),
  244. ) -> dict:
  245. """Return demand_grade rows (one per category_id) for the given/latest biz_dt."""
  246. items = list_demand_grades(biz_dt=biz_dt)
  247. return {"items": items}
  248. @app.get("/api/demand-grade/{demand_grade_id}/videos")
  249. def demand_grade_videos(demand_grade_id: int) -> dict:
  250. """Return expansion videos/points for a demand_grade row (by its biz_dt)."""
  251. result = list_videos_for_demand_grade(demand_grade_id)
  252. if result is None:
  253. raise HTTPException(status_code=404, detail="demand_grade not found")
  254. return result
  255. @app.get("/api/video-discovery/demands")
  256. def video_discovery_demands(
  257. biz_dt: str | None = Query(
  258. default=None,
  259. description="业务日 YYYYMMDD;省略则取 demand_grade 最新一日",
  260. ),
  261. ) -> dict:
  262. """Return demand-first video discovery cards for the selected/latest day."""
  263. return list_video_discovery_demands(biz_dt=biz_dt)
  264. @app.get("/api/video-discovery/runs")
  265. def video_discovery_runs(
  266. biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
  267. status: Literal["running", "finished", "failed"] | None = None,
  268. keyword: str | None = Query(default=None, max_length=256),
  269. aweme_id: str | None = Query(default=None, max_length=64),
  270. limit: int = Query(default=20, ge=1, le=100),
  271. offset: int = Query(default=0, ge=0),
  272. ) -> dict:
  273. """Return paged find-agent runs for the records workspace."""
  274. return list_video_discovery_runs(
  275. biz_dt=biz_dt,
  276. status=status,
  277. keyword=keyword,
  278. aweme_id=aweme_id,
  279. limit=limit,
  280. offset=offset,
  281. )
  282. @app.get("/api/video-discovery/runs/{run_id}")
  283. def video_discovery_run(run_id: str) -> dict:
  284. """Return one find-agent run and its aggregate counts."""
  285. result = get_video_discovery_run(run_id)
  286. if result is None:
  287. raise HTTPException(status_code=404, detail="video discovery run not found")
  288. return result
  289. @app.get("/api/video-discovery/runs/{run_id}/searches")
  290. def video_discovery_searches(
  291. run_id: str,
  292. keyword: str | None = Query(default=None, max_length=256),
  293. limit: int = Query(default=20, ge=1, le=100),
  294. offset: int = Query(default=0, ge=0),
  295. ) -> dict:
  296. """Return paged search-page records for one find-agent run."""
  297. result = list_video_discovery_searches(
  298. run_id,
  299. keyword=keyword,
  300. limit=limit,
  301. offset=offset,
  302. )
  303. if result is None:
  304. raise HTTPException(status_code=404, detail="video discovery run not found")
  305. return result
  306. @app.get("/api/video-discovery/runs/{run_id}/candidates")
  307. def video_discovery_candidates(
  308. run_id: str,
  309. bucket: str | None = Query(default=None, max_length=24),
  310. keyword: str | None = Query(default=None, max_length=256),
  311. aweme_id: str | None = Query(default=None, max_length=64),
  312. limit: int = Query(default=20, ge=1, le=100),
  313. offset: int = Query(default=0, ge=0),
  314. ) -> dict:
  315. """Return paged candidate records for one find-agent run."""
  316. result = list_video_discovery_candidates(
  317. run_id,
  318. bucket=bucket,
  319. keyword=keyword,
  320. aweme_id=aweme_id,
  321. limit=limit,
  322. offset=offset,
  323. )
  324. if result is None:
  325. raise HTTPException(status_code=404, detail="video discovery run not found")
  326. return result
  327. @app.get("/api/video-discovery/demands/{demand_grade_id}")
  328. def video_discovery_demand(demand_grade_id: int) -> dict:
  329. """Return one demand and its videos/points resolved from the source tables."""
  330. result = get_video_discovery_demand(demand_grade_id)
  331. if result is None:
  332. raise HTTPException(status_code=404, detail="demand_grade not found")
  333. return result
  334. @app.post(
  335. "/api/video-discovery/feedback",
  336. status_code=status.HTTP_201_CREATED,
  337. )
  338. def submit_video_discovery_feedback(
  339. body: CreateDemandFeedbackBody,
  340. request: Request,
  341. ) -> dict:
  342. """Append feedback for one demand, video or hit-content record."""
  343. try:
  344. return create_demand_feedback(body, request.state.current_user)
  345. except FeedbackTargetNotFoundError as exc:
  346. raise HTTPException(status_code=404, detail=str(exc)) from exc
  347. except (FeedbackTargetConflictError, FeedbackRequestConflictError) as exc:
  348. raise HTTPException(status_code=409, detail=str(exc)) from exc
  349. @app.get("/api/video-discovery/feedback")
  350. def video_discovery_feedback_history(
  351. target_type: Literal["demand", "video", "hit_content"],
  352. demand_grade_id: int = Query(gt=0),
  353. video_id: str | None = Query(default=None, max_length=64),
  354. demand_video_expansion_id: int | None = Query(default=None, gt=0),
  355. limit: int = Query(default=50, ge=1, le=100),
  356. offset: int = Query(default=0, ge=0),
  357. ) -> dict:
  358. """Return feedback records and feedback people for one target."""
  359. try:
  360. return list_demand_feedback(
  361. target_type=target_type,
  362. demand_grade_id=demand_grade_id,
  363. video_id=video_id,
  364. demand_video_expansion_id=demand_video_expansion_id,
  365. limit=limit,
  366. offset=offset,
  367. )
  368. except FeedbackTargetNotFoundError as exc:
  369. raise HTTPException(status_code=404, detail=str(exc)) from exc
  370. except FeedbackTargetConflictError as exc:
  371. raise HTTPException(status_code=409, detail=str(exc)) from exc
  372. @app.get("/api/demand-belong-oss-logs")
  373. def demand_belong_oss_logs() -> dict:
  374. """Return demand_belong_category_agent oss_logs ordered by create_time desc."""
  375. items = list_demand_belong_oss_logs()
  376. return {"items": items}
  377. @app.get("/api/agent-oss-logs")
  378. def agent_oss_logs(
  379. agent_name: str | None = Query(
  380. default=None,
  381. description="Agent 名称;省略则返回全部 Agent 日志",
  382. ),
  383. ) -> dict:
  384. """Return Agent oss_logs and the available Agent names."""
  385. return list_agent_oss_logs(agent_name=agent_name)
  386. @app.get("/api/agents/{agent_name}")
  387. def agent_detail(agent_name: str) -> dict:
  388. """Return one business Agent's current system prompt and tools."""
  389. result = get_agent_detail(agent_name)
  390. if result is None:
  391. raise HTTPException(status_code=404, detail=f"agent not found: {agent_name}")
  392. return result
  393. @app.put("/api/agents/{agent_name}/document-injection")
  394. def agent_document_injection(
  395. agent_name: str,
  396. body: AgentDocumentInjectionBody,
  397. ) -> dict:
  398. """Create or update the web-managed document injected into an Agent prompt."""
  399. result = update_agent_document_injection(
  400. agent_name,
  401. content=body.content,
  402. enabled=body.enabled,
  403. )
  404. if result is None:
  405. raise HTTPException(status_code=404, detail=f"agent not found: {agent_name}")
  406. return result
  407. _web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
  408. if _web_dist.is_dir():
  409. app.mount("/", SPAStaticFiles(directory=_web_dist, html=True), name="web")