topic_table.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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_v2"
  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. "请根据下面的真实选题表快照,从六类信息中各判断一个需要搜索的问题。"
  107. "knowledge_needs 数组必须正好有 6 项,每类信息正好 1 项,不能把搜索词数量误当成问题数量。"
  108. f"六个问题合计生成约 {target_query_count} 条具体搜索词;数量允许时,每个问题给 2~3 条。"
  109. "decision_context 必须是一句不超过 45 个汉字的大白话,只说大模型要判断什么,不要先解释背景。"
  110. "只输出待搜索的问题,不要在 decision_context 或 unknown_information 中提前编造答案、参数、比例或效果数据。"
  111. "不要启动搜索。\n\n"
  112. + json.dumps(snapshot, ensure_ascii=False, indent=2, default=str)
  113. )
  114. def _safe_need_key(value: Any, route: str, index: int) -> str:
  115. raw = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(value or "").strip()).strip("_")
  116. return raw[:80] or f"{route}_{index + 1}"
  117. def _planning_payload(
  118. llm_output: dict[str, Any],
  119. *,
  120. snapshot: dict[str, Any],
  121. ) -> dict[str, Any]:
  122. raw_needs = llm_output.get("knowledge_needs")
  123. if not isinstance(raw_needs, list) or not raw_needs:
  124. raise TopicTableGenerationError("大模型没有返回六类搜索问题")
  125. topic_build_id = snapshot["topic_build_id"]
  126. topic_id = snapshot["topic_id"]
  127. needs: list[dict[str, Any]] = []
  128. candidates: list[dict[str, Any]] = []
  129. used_keys: set[str] = set()
  130. route_counts: dict[str, int] = {route: 0 for route in ROUTES}
  131. position = 0
  132. for index, raw in enumerate(raw_needs):
  133. if not isinstance(raw, dict):
  134. continue
  135. route = str(raw.get("route") or "").strip()
  136. if route not in ROUTES:
  137. continue
  138. route_counts[route] += 1
  139. need_key = _safe_need_key(raw.get("need_key"), route, index)
  140. if need_key in used_keys:
  141. need_key = f"{need_key}_{index + 1}"
  142. used_keys.add(need_key)
  143. decision_context = str(raw.get("decision_context") or "").strip()
  144. unknown_information = str(raw.get("unknown_information") or "").strip()
  145. if not decision_context or not unknown_information:
  146. continue
  147. priority = max(0, min(100, int(raw.get("priority") or ROUTES[route]["priority"])))
  148. raw_ref = raw.get("source_ref") if isinstance(raw.get("source_ref"), dict) else {}
  149. source_ref = {
  150. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  151. "topic_build_id": topic_build_id,
  152. "topic_id": topic_id,
  153. "route": route,
  154. "point_id": raw_ref.get("point_id"),
  155. "item_ids": list(raw_ref.get("item_ids") or []),
  156. }
  157. needs.append(
  158. {
  159. "need_key": need_key,
  160. "decision_context": decision_context,
  161. "unknown_information": unknown_information,
  162. "source_ref": source_ref,
  163. "priority": priority,
  164. "metadata": {"route": route, "route_label": ROUTES[route]["label"]},
  165. }
  166. )
  167. raw_queries = raw.get("queries") or []
  168. if isinstance(raw_queries, str):
  169. raw_queries = [raw_queries]
  170. for query in raw_queries:
  171. query_text = str(query or "").strip()
  172. if not query_text:
  173. continue
  174. candidates.append(
  175. {
  176. "query_text": query_text,
  177. "axes": {
  178. "需求路径": ROUTES[route]["label"],
  179. "创作决定": decision_context,
  180. "未知信息": unknown_information,
  181. },
  182. "priority": priority,
  183. "original_position": position,
  184. "source_refs": [source_ref],
  185. "knowledge_need_keys": [need_key],
  186. "metadata": {
  187. "family_key": GeneratorKind.TOPIC_TABLE.value,
  188. "route": route,
  189. "route_label": ROUTES[route]["label"],
  190. "need_key": need_key,
  191. },
  192. }
  193. )
  194. position += 1
  195. invalid_routes = [
  196. ROUTES[route]["label"]
  197. for route, count in route_counts.items()
  198. if count != 1
  199. ]
  200. if invalid_routes:
  201. raise TopicTableGenerationError(
  202. "大模型返回的六类问题不完整或有重复:" + "、".join(invalid_routes)
  203. )
  204. if not needs or not candidates:
  205. raise TopicTableGenerationError("大模型返回内容无法形成搜索问题和搜索词")
  206. return {
  207. "input_snapshot": snapshot,
  208. "knowledge_needs": needs,
  209. "candidates": candidates,
  210. "generator_config": {
  211. "prompt_name": PROMPT_NAME,
  212. "prompt_version": PROMPT_VERSION,
  213. "routes": list(ROUTES),
  214. },
  215. }
  216. def generate_topic_table_preview(
  217. source_payload: dict[str, Any],
  218. *,
  219. topic_id: int | None,
  220. max_queries: int | None,
  221. chat_fn: Callable[[str, str], dict[str, Any]],
  222. ) -> TopicTablePreview:
  223. topic = _select_topic(source_payload, topic_id)
  224. snapshot = _compact_snapshot(source_payload, topic)
  225. llm_output = chat_fn(
  226. topic_table_prompt(),
  227. build_topic_table_user_prompt(
  228. snapshot,
  229. target_query_count=max_queries or 18,
  230. ),
  231. )
  232. if not isinstance(llm_output, dict):
  233. raise TopicTableGenerationError("大模型返回格式不正确")
  234. payload = _planning_payload(llm_output, snapshot=snapshot)
  235. request = GenerationRequest(
  236. generator_kind=GeneratorKind.TOPIC_TABLE,
  237. payload=payload,
  238. name=f"topic-table-{snapshot['topic_build_id']}-{snapshot['topic_id']}",
  239. max_queries=max_queries,
  240. metadata={
  241. "source": "pattern_topic_build_api",
  242. "prompt_version": PROMPT_VERSION,
  243. },
  244. )
  245. result = UnifiedQueryGenerationService().generate(request)
  246. return TopicTablePreview(
  247. result=result,
  248. source={
  249. "topic_build_id": snapshot["topic_build_id"],
  250. "topic_id": snapshot["topic_id"],
  251. "source_url": source_payload.get("source_url"),
  252. },
  253. topic=snapshot["topic"],
  254. llm_output=llm_output,
  255. )
  256. def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
  257. result = preview.result
  258. route_counts: dict[str, int] = {route: 0 for route in ROUTES}
  259. for need in result.knowledge_needs:
  260. route = str(need.metadata.get("route") or "")
  261. if route in route_counts:
  262. route_counts[route] += 1
  263. dimensions: dict[str, list[str]] = {"实质": [], "形式": [], "意图": []}
  264. reference_notes: list[dict[str, str]] = []
  265. for item in preview.topic.get("composition_items") or []:
  266. dimension = item.get("dimension")
  267. name = item.get("element_name")
  268. if dimension in dimensions and name and item.get("is_active"):
  269. dimensions[dimension].append(name)
  270. for source in item.get("sources") or []:
  271. note = str(source.get("reason") or item.get("reason") or "").strip()
  272. if name and note:
  273. reference_notes.append({"element": str(name), "note": note})
  274. point_groups: dict[str, list[str]] = {"灵感点": [], "目的点": [], "关键点": []}
  275. for point in preview.topic.get("points") or []:
  276. point_type = str(point.get("point_type") or "")
  277. point_result = str(point.get("point_result") or "").strip()
  278. if point_type in point_groups and point_result:
  279. point_groups[point_type].append(point_result)
  280. return {
  281. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  282. "source": preview.source,
  283. "topic": {
  284. "status": preview.topic.get("status"),
  285. "result": preview.topic.get("result"),
  286. "topic_direction": preview.topic.get("topic_direction"),
  287. "dimensions": dimensions,
  288. "points": preview.topic.get("points") or [],
  289. "input_groups": {
  290. "points": point_groups,
  291. "reference_notes": reference_notes[:8],
  292. },
  293. },
  294. "knowledge_needs": [
  295. {
  296. "need_key": need.need_key,
  297. "route": need.metadata.get("route"),
  298. "route_label": need.metadata.get("route_label"),
  299. "decision_context": need.decision_context,
  300. "unknown_information": need.unknown_information,
  301. "priority": need.priority,
  302. "source_ref": need.source_ref,
  303. }
  304. for need in result.knowledge_needs
  305. ],
  306. "queries": [
  307. {
  308. "query": query.query_text,
  309. "priority": query.priority,
  310. "route": query.metadata.get("route"),
  311. "route_label": query.metadata.get("route_label"),
  312. "need_key": query.metadata.get("need_key"),
  313. "axes": query.axes,
  314. "source_refs": query.source_refs,
  315. }
  316. for query in result.selected_queries
  317. ],
  318. "summary": {
  319. "generated_count": result.stats.generated_count,
  320. "unique_count": result.stats.unique_count,
  321. "selected_count": result.stats.selected_count,
  322. "dropped_count": result.stats.dropped_count,
  323. "route_counts": route_counts,
  324. "persisted": False,
  325. "search_started": False,
  326. },
  327. "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
  328. }