query_generation.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """Formal query-generation preview API routes."""
  2. from __future__ import annotations
  3. from typing import Any
  4. from uuid import UUID
  5. from fastapi import APIRouter, Depends, HTTPException, Query
  6. from acquisition.queries.builder import QueryBuildOptions, TREES, build_creation_query_batch
  7. from app.dependencies import _env_file, get_acquisition_repository
  8. from app.routes.acquisition import _model_dump
  9. from app.schemas import QuerySchema, TopicTableQueryPreviewRequest
  10. from core.config import Settings
  11. from core.llm import LLMError, chat_json
  12. from query_planning.sources import (
  13. PatternTopicBuildConfig,
  14. PatternTopicBuildSource,
  15. TopicBuildSourceError,
  16. )
  17. from query_planning.topic_table import (
  18. PROMPT_NAME,
  19. PROMPT_VERSION,
  20. ROUTES,
  21. TopicTableGenerationError,
  22. generate_topic_table_preview,
  23. preview_to_dict,
  24. topic_table_source_preview_to_dict,
  25. topic_table_prompt,
  26. )
  27. router = APIRouter(prefix="/api/query-generation", tags=["query-generation"])
  28. def _summary(generated: dict[str, Any]) -> dict[str, Any]:
  29. families = generated.get("families") or []
  30. return {
  31. "family_count": len(families),
  32. "family_keys": [family.get("key") for family in families],
  33. "query_count": sum(len(family.get("items") or []) for family in families),
  34. "kept_count": sum(
  35. 1
  36. for family in families
  37. for item in family.get("items") or []
  38. if item.get("keep", True)
  39. ),
  40. }
  41. LIGHT_ITEM_METADATA_KEYS = {
  42. "page_index",
  43. "page_rank",
  44. "source_cursor",
  45. "content_mode",
  46. "search_provider",
  47. "detail_provider",
  48. "acquisition_match_status",
  49. "matched_unique_key",
  50. "skip_reason",
  51. "unsupported_raw_type",
  52. "video_url_missing",
  53. }
  54. def _light_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
  55. if not isinstance(metadata, dict):
  56. return {}
  57. return {key: metadata[key] for key in LIGHT_ITEM_METADATA_KEYS if key in metadata}
  58. def _normalize_light_query_detail(detail: dict[str, Any]) -> dict[str, Any]:
  59. media_by_item = {media["item_id"]: media for media in detail.get("media_assets") or []}
  60. classification_by_item = {row["item_id"]: row for row in detail.get("classifications") or []}
  61. decode_by_item = {row["item_id"]: row for row in detail.get("decode_summaries") or []}
  62. platforms: dict[str, dict[str, Any]] = {}
  63. for raw_job in detail.get("jobs") or []:
  64. job = _model_dump(raw_job)
  65. platform = job.get("platform")
  66. if not platform:
  67. continue
  68. platforms.setdefault(
  69. platform,
  70. {
  71. "platform": platform,
  72. "status": job.get("status") or "pending",
  73. "attempt_count": job.get("attempt_count"),
  74. "display_limit": job.get("display_limit"),
  75. "search_limit": job.get("search_limit"),
  76. "error_message": job.get("error_message"),
  77. "item_ids": [],
  78. "item_count": 0,
  79. },
  80. )
  81. items: list[dict[str, Any]] = []
  82. for row in detail.get("items") or []:
  83. item = _model_dump(row)
  84. item_id = item["id"]
  85. classification = classification_by_item.get(item_id)
  86. payload = {
  87. "id": item_id,
  88. "platform": item.get("platform"),
  89. "title": item.get("title"),
  90. "raw_summary": item.get("raw_summary"),
  91. "status": item.get("status"),
  92. "content_mode": item.get("content_mode"),
  93. "metadata": _light_metadata(item.get("metadata")),
  94. "classification": _model_dump(classification) if classification else None,
  95. "decode_summary": _model_dump(decode_by_item[item_id]) if item_id in decode_by_item else None,
  96. "media_assets": [],
  97. }
  98. if item_id in media_by_item:
  99. payload["media_assets"] = [_model_dump(media_by_item[item_id])]
  100. items.append(payload)
  101. platform = payload["platform"]
  102. group = platforms.setdefault(
  103. platform,
  104. {"platform": platform, "status": "done", "item_ids": [], "item_count": 0},
  105. )
  106. if group.get("status") in {None, "pending"}:
  107. group["status"] = "done"
  108. group.setdefault("item_ids", []).append(item_id)
  109. group["item_count"] = len(group["item_ids"])
  110. return {
  111. "query": QuerySchema.model_validate(detail["query"]).model_dump(mode="json"),
  112. "run": _model_dump(detail["run"]) if detail.get("run") else None,
  113. "jobs": [_model_dump(job) for job in detail.get("jobs") or []],
  114. "items": items,
  115. "platforms": platforms,
  116. }
  117. @router.get("/preview")
  118. def query_generation_preview(
  119. per: int = Query(default=0, ge=0, le=10000),
  120. batch_n: int = Query(default=0, ge=0, le=1000),
  121. ) -> dict[str, Any]:
  122. """Preview the currently active formal query families without writing DB rows."""
  123. settings = Settings.from_env(_env_file())
  124. generated = build_creation_query_batch(
  125. settings,
  126. tree_path=TREES,
  127. options=QueryBuildOptions(
  128. per=per,
  129. batch_n=batch_n,
  130. active_family_keys=("f1", "f2"),
  131. ),
  132. )
  133. generated["summary"] = _summary(generated)
  134. return generated
  135. def _topic_source(env_file: str) -> PatternTopicBuildSource:
  136. return PatternTopicBuildSource(PatternTopicBuildConfig.from_env(env_file))
  137. def _topic_query_chat(system: str, user: str, *, settings: Settings) -> dict[str, Any]:
  138. return chat_json(
  139. system,
  140. user,
  141. settings=settings,
  142. timeout=settings.bailian_timeout_seconds,
  143. trace_stage="query_planning",
  144. trace_substage="topic_table_query_generation",
  145. prompt_name=PROMPT_NAME,
  146. )
  147. @router.get("/topic-table/prompt")
  148. def topic_table_generation_prompt() -> dict[str, Any]:
  149. """Return the exact versioned Prompt shown by the topic-table preview UI."""
  150. return {
  151. "name": PROMPT_NAME,
  152. "version": PROMPT_VERSION,
  153. "routes": [
  154. {"key": key, "label": value["label"], "default_priority": value["priority"]}
  155. for key, value in ROUTES.items()
  156. ],
  157. "system_prompt": topic_table_prompt(),
  158. "example_request": {
  159. "topic_build_id": 1229,
  160. "topic_id": 1392,
  161. "max_queries": 18,
  162. },
  163. }
  164. @router.post("/topic-table/preview")
  165. def topic_table_query_preview(request: TopicTableQueryPreviewRequest) -> dict[str, Any]:
  166. """Generate Query preview from one topic table without persisting or searching."""
  167. env_file = _env_file()
  168. try:
  169. source_payload = _topic_source(env_file).fetch(request.topic_build_id)
  170. settings = Settings.from_env(env_file)
  171. preview = generate_topic_table_preview(
  172. source_payload,
  173. topic_id=request.topic_id,
  174. max_queries=request.max_queries,
  175. chat_fn=lambda system, user: _topic_query_chat(
  176. system,
  177. user,
  178. settings=settings,
  179. ),
  180. )
  181. return preview_to_dict(preview)
  182. except TopicBuildSourceError as exc:
  183. raise HTTPException(status_code=502, detail=str(exc)) from exc
  184. except LLMError as exc:
  185. raise HTTPException(status_code=502, detail=f"Query 生成模型调用失败:{exc}") from exc
  186. except TopicTableGenerationError as exc:
  187. raise HTTPException(status_code=422, detail=str(exc)) from exc
  188. @router.post("/topic-table/source-preview")
  189. def topic_table_source_preview(request: TopicTableQueryPreviewRequest) -> dict[str, Any]:
  190. """Read all topic-table source fields without calling an LLM."""
  191. try:
  192. source_payload = _topic_source(_env_file()).fetch(request.topic_build_id)
  193. return topic_table_source_preview_to_dict(
  194. source_payload,
  195. topic_id=request.topic_id,
  196. )
  197. except TopicBuildSourceError as exc:
  198. raise HTTPException(status_code=502, detail=str(exc)) from exc
  199. except TopicTableGenerationError as exc:
  200. raise HTTPException(status_code=422, detail=str(exc)) from exc
  201. @router.get("/latest-singleton")
  202. def latest_singleton_overview(repo: Any = Depends(get_acquisition_repository)) -> dict[str, Any]:
  203. """Return links from the query preview board to the latest real singleton run."""
  204. getter = getattr(repo, "get_latest_singleton_overview", None)
  205. if getter is None:
  206. return {"batch": None, "run": None, "queries": [], "decoded_items": []}
  207. return getter()
  208. @router.get("/latest/queries/{query_id}")
  209. def latest_query_detail(
  210. query_id: UUID,
  211. repo: Any = Depends(get_acquisition_repository),
  212. ) -> dict[str, Any]:
  213. """Return search material for one query in the latest real query board batch."""
  214. detail_getter = getattr(repo, "get_latest_query_result_list", None)
  215. if detail_getter is None:
  216. return {"query": None, "jobs": [], "items": [], "platforms": {}}
  217. return _normalize_light_query_detail(detail_getter(query_id))