topic_table.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """Application service for deriving Query candidates from one topic-table snapshot."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from copy import deepcopy
  6. from dataclasses import dataclass
  7. from typing import Any, Callable
  8. from core.prompts import load_prompt
  9. from query_planning.domain import GenerationRequest, GenerationResult, GeneratorKind
  10. from query_planning.service import UnifiedQueryGenerationService
  11. PROMPT_NAME = "topic_table_query_generation"
  12. PROMPT_VERSION = "topic_table_query_v1"
  13. ROUTES: dict[str, dict[str, Any]] = {
  14. "unique_hook": {"label": "独特切口落地", "priority": 100},
  15. "goal_execution": {"label": "创作目标实现", "priority": 90},
  16. "key_execution": {"label": "关键点执行", "priority": 80},
  17. "cross_dimension": {"label": "跨维度组合", "priority": 70},
  18. "account_fit": {"label": "账号适配", "priority": 60},
  19. "evidence_gap": {"label": "来源与推导缺口", "priority": 50},
  20. }
  21. class TopicTableGenerationError(ValueError):
  22. """Raised when a topic snapshot or LLM result cannot become a Query plan."""
  23. @dataclass(frozen=True)
  24. class TopicTablePreview:
  25. result: GenerationResult
  26. source: dict[str, Any]
  27. topic: dict[str, Any]
  28. llm_output: dict[str, Any]
  29. def topic_table_prompt() -> str:
  30. return load_prompt(PROMPT_NAME)
  31. def _select_topic(payload: dict[str, Any], topic_id: int | None) -> dict[str, Any]:
  32. topics = [topic for topic in payload.get("topics") or [] if isinstance(topic, dict)]
  33. if topic_id is not None:
  34. for topic in topics:
  35. if int(topic.get("id") or 0) == topic_id:
  36. return topic
  37. raise TopicTableGenerationError(f"选题构建中找不到 topic_id={topic_id}")
  38. mature = next((topic for topic in topics if topic.get("status") == "mature"), None)
  39. if mature is not None:
  40. return mature
  41. if topics:
  42. return topics[0]
  43. raise TopicTableGenerationError("选题构建中没有可用 topic")
  44. def _source_summary(source: dict[str, Any]) -> dict[str, Any]:
  45. return {
  46. "derivation_type": source.get("derivation_type"),
  47. "source_type": source.get("source_type"),
  48. "source_reference_id": source.get("source_reference_id"),
  49. "reason": source.get("reason"),
  50. "dataset_from": source.get("dataset_from"),
  51. }
  52. def _compact_snapshot(
  53. payload: dict[str, Any],
  54. topic: dict[str, Any],
  55. ) -> dict[str, Any]:
  56. build = payload.get("build") or {}
  57. items = []
  58. for item in topic.get("composition_items") or []:
  59. if not isinstance(item, dict) or item.get("is_active") is False:
  60. continue
  61. items.append(
  62. {
  63. "id": item.get("id"),
  64. "item_level": item.get("item_level"),
  65. "dimension": item.get("dimension"),
  66. "point_type": item.get("point_type"),
  67. "element_name": item.get("element_name"),
  68. "category_path": item.get("category_path"),
  69. "reason": item.get("reason"),
  70. "is_active": item.get("is_active"),
  71. "sources": [
  72. _source_summary(source)
  73. for source in item.get("sources") or []
  74. if isinstance(source, dict)
  75. ],
  76. }
  77. )
  78. return {
  79. "topic_build_id": build.get("id"),
  80. "topic_id": topic.get("id"),
  81. "build": {
  82. "demand": build.get("demand"),
  83. "demand_constraints": build.get("demand_constraints"),
  84. "strategies_config": build.get("strategies_config"),
  85. "status": build.get("status"),
  86. },
  87. "topic": {
  88. "status": topic.get("status"),
  89. "result": topic.get("result"),
  90. "topic_direction": topic.get("topic_direction"),
  91. "points": [
  92. deepcopy(point)
  93. for point in topic.get("points") or []
  94. if isinstance(point, dict) and point.get("is_active") is not False
  95. ],
  96. "composition_items": items,
  97. "item_relations": deepcopy(topic.get("item_relations") or []),
  98. },
  99. }
  100. def build_topic_table_user_prompt(
  101. snapshot: dict[str, Any],
  102. *,
  103. target_query_count: int,
  104. ) -> str:
  105. return (
  106. "请根据下面的真实选题表快照,沿六条固定路径识别 Knowledge Need。"
  107. "knowledge_needs 数组必须正好有 6 项,每条路径正好 1 项,不能把 Query 数量误当成 Need 数量。"
  108. f"六个 Need 合计生成约 {target_query_count} 条原子 Query;预算允许时,每个 Need 给 2~3 条。"
  109. "只输出待搜索的问题,不要在 decision_context 或 unknown_information 中提前编造答案、参数、比例或效果数据。"
  110. "不要启动搜索。\n\n"
  111. + json.dumps(snapshot, ensure_ascii=False, indent=2, default=str)
  112. )
  113. def _safe_need_key(value: Any, route: str, index: int) -> str:
  114. raw = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(value or "").strip()).strip("_")
  115. return raw[:80] or f"{route}_{index + 1}"
  116. def _planning_payload(
  117. llm_output: dict[str, Any],
  118. *,
  119. snapshot: dict[str, Any],
  120. ) -> dict[str, Any]:
  121. raw_needs = llm_output.get("knowledge_needs")
  122. if not isinstance(raw_needs, list) or not raw_needs:
  123. raise TopicTableGenerationError("LLM 没有返回 knowledge_needs")
  124. topic_build_id = snapshot["topic_build_id"]
  125. topic_id = snapshot["topic_id"]
  126. needs: list[dict[str, Any]] = []
  127. candidates: list[dict[str, Any]] = []
  128. used_keys: set[str] = set()
  129. route_counts: dict[str, int] = {route: 0 for route in ROUTES}
  130. position = 0
  131. for index, raw in enumerate(raw_needs):
  132. if not isinstance(raw, dict):
  133. continue
  134. route = str(raw.get("route") or "").strip()
  135. if route not in ROUTES:
  136. continue
  137. route_counts[route] += 1
  138. need_key = _safe_need_key(raw.get("need_key"), route, index)
  139. if need_key in used_keys:
  140. need_key = f"{need_key}_{index + 1}"
  141. used_keys.add(need_key)
  142. decision_context = str(raw.get("decision_context") or "").strip()
  143. unknown_information = str(raw.get("unknown_information") or "").strip()
  144. if not decision_context or not unknown_information:
  145. continue
  146. priority = max(0, min(100, int(raw.get("priority") or ROUTES[route]["priority"])))
  147. raw_ref = raw.get("source_ref") if isinstance(raw.get("source_ref"), dict) else {}
  148. source_ref = {
  149. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  150. "topic_build_id": topic_build_id,
  151. "topic_id": topic_id,
  152. "route": route,
  153. "point_id": raw_ref.get("point_id"),
  154. "item_ids": list(raw_ref.get("item_ids") or []),
  155. }
  156. needs.append(
  157. {
  158. "need_key": need_key,
  159. "decision_context": decision_context,
  160. "unknown_information": unknown_information,
  161. "source_ref": source_ref,
  162. "priority": priority,
  163. "metadata": {"route": route, "route_label": ROUTES[route]["label"]},
  164. }
  165. )
  166. raw_queries = raw.get("queries") or []
  167. if isinstance(raw_queries, str):
  168. raw_queries = [raw_queries]
  169. for query in raw_queries:
  170. query_text = str(query or "").strip()
  171. if not query_text:
  172. continue
  173. candidates.append(
  174. {
  175. "query_text": query_text,
  176. "axes": {
  177. "需求路径": ROUTES[route]["label"],
  178. "创作决定": decision_context,
  179. "未知信息": unknown_information,
  180. },
  181. "priority": priority,
  182. "original_position": position,
  183. "source_refs": [source_ref],
  184. "knowledge_need_keys": [need_key],
  185. "metadata": {
  186. "family_key": GeneratorKind.TOPIC_TABLE.value,
  187. "route": route,
  188. "route_label": ROUTES[route]["label"],
  189. "need_key": need_key,
  190. },
  191. }
  192. )
  193. position += 1
  194. invalid_routes = [
  195. ROUTES[route]["label"]
  196. for route, count in route_counts.items()
  197. if count != 1
  198. ]
  199. if invalid_routes:
  200. raise TopicTableGenerationError(
  201. "LLM 返回的六路 Knowledge Need 不完整或有重复:" + "、".join(invalid_routes)
  202. )
  203. if not needs or not candidates:
  204. raise TopicTableGenerationError("LLM 返回内容无法形成 Knowledge Need 和 Query")
  205. return {
  206. "input_snapshot": snapshot,
  207. "knowledge_needs": needs,
  208. "candidates": candidates,
  209. "generator_config": {
  210. "prompt_name": PROMPT_NAME,
  211. "prompt_version": PROMPT_VERSION,
  212. "routes": list(ROUTES),
  213. },
  214. }
  215. def generate_topic_table_preview(
  216. source_payload: dict[str, Any],
  217. *,
  218. topic_id: int | None,
  219. max_queries: int | None,
  220. chat_fn: Callable[[str, str], dict[str, Any]],
  221. ) -> TopicTablePreview:
  222. topic = _select_topic(source_payload, topic_id)
  223. snapshot = _compact_snapshot(source_payload, topic)
  224. llm_output = chat_fn(
  225. topic_table_prompt(),
  226. build_topic_table_user_prompt(
  227. snapshot,
  228. target_query_count=max_queries or 18,
  229. ),
  230. )
  231. if not isinstance(llm_output, dict):
  232. raise TopicTableGenerationError("LLM 返回值不是 JSON 对象")
  233. payload = _planning_payload(llm_output, snapshot=snapshot)
  234. request = GenerationRequest(
  235. generator_kind=GeneratorKind.TOPIC_TABLE,
  236. payload=payload,
  237. name=f"topic-table-{snapshot['topic_build_id']}-{snapshot['topic_id']}",
  238. max_queries=max_queries,
  239. metadata={
  240. "source": "pattern_topic_build_api",
  241. "prompt_version": PROMPT_VERSION,
  242. },
  243. )
  244. result = UnifiedQueryGenerationService().generate(request)
  245. return TopicTablePreview(
  246. result=result,
  247. source={
  248. "topic_build_id": snapshot["topic_build_id"],
  249. "topic_id": snapshot["topic_id"],
  250. "source_url": source_payload.get("source_url"),
  251. },
  252. topic=snapshot["topic"],
  253. llm_output=llm_output,
  254. )
  255. def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
  256. result = preview.result
  257. route_counts: dict[str, int] = {route: 0 for route in ROUTES}
  258. for need in result.knowledge_needs:
  259. route = str(need.metadata.get("route") or "")
  260. if route in route_counts:
  261. route_counts[route] += 1
  262. dimensions: dict[str, list[str]] = {"实质": [], "形式": [], "意图": []}
  263. for item in preview.topic.get("composition_items") or []:
  264. dimension = item.get("dimension")
  265. name = item.get("element_name")
  266. if dimension in dimensions and name and item.get("is_active"):
  267. dimensions[dimension].append(name)
  268. return {
  269. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  270. "source": preview.source,
  271. "topic": {
  272. "status": preview.topic.get("status"),
  273. "result": preview.topic.get("result"),
  274. "topic_direction": preview.topic.get("topic_direction"),
  275. "dimensions": dimensions,
  276. "points": preview.topic.get("points") or [],
  277. },
  278. "knowledge_needs": [
  279. {
  280. "need_key": need.need_key,
  281. "route": need.metadata.get("route"),
  282. "route_label": need.metadata.get("route_label"),
  283. "decision_context": need.decision_context,
  284. "unknown_information": need.unknown_information,
  285. "priority": need.priority,
  286. "source_ref": need.source_ref,
  287. }
  288. for need in result.knowledge_needs
  289. ],
  290. "queries": [
  291. {
  292. "query": query.query_text,
  293. "priority": query.priority,
  294. "route": query.metadata.get("route"),
  295. "route_label": query.metadata.get("route_label"),
  296. "need_key": query.metadata.get("need_key"),
  297. "axes": query.axes,
  298. "source_refs": query.source_refs,
  299. }
  300. for query in result.selected_queries
  301. ],
  302. "summary": {
  303. "generated_count": result.stats.generated_count,
  304. "unique_count": result.stats.unique_count,
  305. "selected_count": result.stats.selected_count,
  306. "dropped_count": result.stats.dropped_count,
  307. "route_counts": route_counts,
  308. "persisted": False,
  309. "search_started": False,
  310. },
  311. "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
  312. }