topic_table.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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_v3"
  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. field_catalog: list[dict[str, Any]],
  103. *,
  104. target_query_count: int,
  105. ) -> str:
  106. return (
  107. "请从 field_catalog 中选择真正能产生创作方法搜索词的具体字段。"
  108. "每一项 field_mapping 只能引用一个真实 field_key;不能合并多个字段,也不能自己编字段。"
  109. f"所有字段合计生成约 {target_query_count} 条具体搜索词,每个字段生成 1~3 条。"
  110. "judgement 必须是一句不超过 45 个汉字的大白话,只说明大模型针对该字段要判断什么。"
  111. "不要提前编造答案、参数、比例或效果数据,不要启动搜索。\n\n"
  112. + json.dumps(
  113. {
  114. "topic_build_id": snapshot["topic_build_id"],
  115. "topic_id": snapshot["topic_id"],
  116. "field_catalog": field_catalog,
  117. },
  118. ensure_ascii=False,
  119. indent=2,
  120. default=str,
  121. )
  122. )
  123. def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
  124. topic = snapshot["topic"]
  125. topic_id = snapshot["topic_id"]
  126. fields: list[dict[str, Any]] = []
  127. def add(
  128. *,
  129. field_key: str,
  130. field_path: str,
  131. field_label: str,
  132. field_value: Any,
  133. route: str,
  134. point_id: Any = None,
  135. item_ids: list[Any] | None = None,
  136. ) -> None:
  137. value = str(field_value or "").strip()
  138. if not value:
  139. return
  140. fields.append(
  141. {
  142. "field_key": field_key,
  143. "field_path": field_path,
  144. "field_label": field_label,
  145. "field_value": value,
  146. "route": route,
  147. "point_id": point_id,
  148. "item_ids": list(item_ids or []),
  149. }
  150. )
  151. add(
  152. field_key="topic.result",
  153. field_path=f"topics[id={topic_id}].result",
  154. field_label="最终选题描述",
  155. field_value=topic.get("result"),
  156. route="cross_dimension",
  157. )
  158. direction = topic.get("topic_direction") or {}
  159. for preference, label in (
  160. ("实质偏好", "账号实质偏好"),
  161. ("形式偏好", "账号形式偏好"),
  162. ("意图偏好", "账号意图偏好"),
  163. ):
  164. add(
  165. field_key=f"topic_direction.persona_dimensions.{preference}",
  166. field_path=f"topics[id={topic_id}].topic_direction.persona_dimensions.{preference}",
  167. field_label=label,
  168. field_value=(direction.get("persona_dimensions") or {}).get(preference),
  169. route="account_fit",
  170. )
  171. point_routes = {
  172. "灵感点": "unique_hook",
  173. "目的点": "goal_execution",
  174. "关键点": "key_execution",
  175. }
  176. for point in topic.get("points") or []:
  177. point_id = point.get("id")
  178. point_type = str(point.get("point_type") or "选题点")
  179. add(
  180. field_key=f"point:{point_id}:point_result",
  181. field_path=f"topics[id={topic_id}].points[id={point_id}].point_result",
  182. field_label=f"{point_type}字段",
  183. field_value=point.get("point_result"),
  184. route=point_routes.get(point_type, "cross_dimension"),
  185. point_id=point_id,
  186. item_ids=list(point.get("item_ids") or []),
  187. )
  188. for item in topic.get("composition_items") or []:
  189. item_id = item.get("id")
  190. dimension = str(item.get("dimension") or "元素")
  191. point_type = str(item.get("point_type") or "")
  192. add(
  193. field_key=f"item:{item_id}:element_name",
  194. field_path=(
  195. f"topics[id={topic_id}].composition_items[id={item_id}].element_name"
  196. ),
  197. field_label=f"{dimension}字段",
  198. field_value=item.get("element_name"),
  199. route=point_routes.get(point_type, "cross_dimension"),
  200. item_ids=[item_id],
  201. )
  202. for source_index, source in enumerate(item.get("sources") or []):
  203. add(
  204. field_key=f"item:{item_id}:source:{source_index}:reason",
  205. field_path=(
  206. f"topics[id={topic_id}].composition_items[id={item_id}]"
  207. f".sources[{source_index}].reason"
  208. ),
  209. field_label="参考来源说明",
  210. field_value=source.get("reason"),
  211. route="evidence_gap",
  212. item_ids=[item_id],
  213. )
  214. for relation_index, relation in enumerate(topic.get("item_relations") or []):
  215. add(
  216. field_key=f"relation:{relation_index}:reason",
  217. field_path=f"topics[id={topic_id}].item_relations[{relation_index}].reason",
  218. field_label="元素关系说明",
  219. field_value=relation.get("reason"),
  220. route="cross_dimension",
  221. item_ids=[
  222. value
  223. for value in (
  224. relation.get("source_item_id"),
  225. relation.get("target_item_id"),
  226. )
  227. if value is not None
  228. ],
  229. )
  230. return fields
  231. def _safe_need_key(value: Any, route: str, index: int) -> str:
  232. raw = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(value or "").strip()).strip("_")
  233. return raw[:80] or f"{route}_{index + 1}"
  234. def _planning_payload(
  235. llm_output: dict[str, Any],
  236. *,
  237. snapshot: dict[str, Any],
  238. field_catalog: list[dict[str, Any]],
  239. ) -> dict[str, Any]:
  240. raw_mappings = llm_output.get("field_mappings")
  241. if not isinstance(raw_mappings, list) or not raw_mappings:
  242. raise TopicTableGenerationError("大模型没有返回字段与搜索词的对应关系")
  243. topic_build_id = snapshot["topic_build_id"]
  244. topic_id = snapshot["topic_id"]
  245. catalog_by_key = {field["field_key"]: field for field in field_catalog}
  246. needs: list[dict[str, Any]] = []
  247. candidates: list[dict[str, Any]] = []
  248. used_field_keys: set[str] = set()
  249. position = 0
  250. for index, raw in enumerate(raw_mappings):
  251. if not isinstance(raw, dict):
  252. continue
  253. field_key = str(raw.get("field_key") or "").strip()
  254. field = catalog_by_key.get(field_key)
  255. if field is None:
  256. raise TopicTableGenerationError(f"大模型引用了不存在的选题表字段:{field_key}")
  257. if field_key in used_field_keys:
  258. raise TopicTableGenerationError(f"大模型重复引用了同一个选题表字段:{field_key}")
  259. used_field_keys.add(field_key)
  260. judgement = str(raw.get("judgement") or "").strip()
  261. if not judgement:
  262. continue
  263. route = field["route"]
  264. priority = ROUTES[route]["priority"]
  265. need_key = _safe_need_key(field_key, route, index)
  266. source_ref = {
  267. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  268. "topic_build_id": topic_build_id,
  269. "topic_id": topic_id,
  270. "route": route,
  271. "field_key": field["field_key"],
  272. "field_path": field["field_path"],
  273. "field_label": field["field_label"],
  274. "field_value": field["field_value"],
  275. "point_id": field.get("point_id"),
  276. "item_ids": list(field.get("item_ids") or []),
  277. }
  278. needs.append(
  279. {
  280. "need_key": need_key,
  281. "decision_context": judgement,
  282. "unknown_information": judgement,
  283. "source_ref": source_ref,
  284. "priority": priority,
  285. "metadata": {
  286. "route": route,
  287. "route_label": ROUTES[route]["label"],
  288. "source_field": field,
  289. },
  290. }
  291. )
  292. raw_queries = raw.get("queries") or []
  293. if isinstance(raw_queries, str):
  294. raw_queries = [raw_queries]
  295. for query in raw_queries:
  296. query_text = str(query or "").strip()
  297. if not query_text:
  298. continue
  299. candidates.append(
  300. {
  301. "query_text": query_text,
  302. "axes": {
  303. "选题表字段": field["field_path"],
  304. "字段值": field["field_value"],
  305. "大模型判断": judgement,
  306. },
  307. "priority": priority,
  308. "original_position": position,
  309. "source_refs": [source_ref],
  310. "knowledge_need_keys": [need_key],
  311. "metadata": {
  312. "family_key": GeneratorKind.TOPIC_TABLE.value,
  313. "route": route,
  314. "route_label": ROUTES[route]["label"],
  315. "need_key": need_key,
  316. },
  317. }
  318. )
  319. position += 1
  320. if not needs or not candidates:
  321. raise TopicTableGenerationError("大模型返回内容无法形成字段与搜索词的对应关系")
  322. return {
  323. "input_snapshot": snapshot,
  324. "knowledge_needs": needs,
  325. "candidates": candidates,
  326. "generator_config": {
  327. "prompt_name": PROMPT_NAME,
  328. "prompt_version": PROMPT_VERSION,
  329. "field_count": len(field_catalog),
  330. },
  331. }
  332. def generate_topic_table_preview(
  333. source_payload: dict[str, Any],
  334. *,
  335. topic_id: int | None,
  336. max_queries: int | None,
  337. chat_fn: Callable[[str, str], dict[str, Any]],
  338. ) -> TopicTablePreview:
  339. topic = _select_topic(source_payload, topic_id)
  340. snapshot = _compact_snapshot(source_payload, topic)
  341. field_catalog = _field_catalog(snapshot)
  342. llm_output = chat_fn(
  343. topic_table_prompt(),
  344. build_topic_table_user_prompt(
  345. snapshot,
  346. field_catalog,
  347. target_query_count=max_queries or 18,
  348. ),
  349. )
  350. if not isinstance(llm_output, dict):
  351. raise TopicTableGenerationError("大模型返回格式不正确")
  352. payload = _planning_payload(
  353. llm_output,
  354. snapshot=snapshot,
  355. field_catalog=field_catalog,
  356. )
  357. request = GenerationRequest(
  358. generator_kind=GeneratorKind.TOPIC_TABLE,
  359. payload=payload,
  360. name=f"topic-table-{snapshot['topic_build_id']}-{snapshot['topic_id']}",
  361. max_queries=max_queries,
  362. metadata={
  363. "source": "pattern_topic_build_api",
  364. "prompt_version": PROMPT_VERSION,
  365. },
  366. )
  367. result = UnifiedQueryGenerationService().generate(request)
  368. return TopicTablePreview(
  369. result=result,
  370. source={
  371. "topic_build_id": snapshot["topic_build_id"],
  372. "topic_id": snapshot["topic_id"],
  373. "source_url": source_payload.get("source_url"),
  374. },
  375. topic=snapshot["topic"],
  376. llm_output=llm_output,
  377. )
  378. def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
  379. result = preview.result
  380. route_counts: dict[str, int] = {route: 0 for route in ROUTES}
  381. for need in result.knowledge_needs:
  382. route = str(need.metadata.get("route") or "")
  383. if route in route_counts:
  384. route_counts[route] += 1
  385. dimensions: dict[str, list[str]] = {"实质": [], "形式": [], "意图": []}
  386. reference_notes: list[dict[str, str]] = []
  387. for item in preview.topic.get("composition_items") or []:
  388. dimension = item.get("dimension")
  389. name = item.get("element_name")
  390. if dimension in dimensions and name and item.get("is_active"):
  391. dimensions[dimension].append(name)
  392. for source in item.get("sources") or []:
  393. note = str(source.get("reason") or item.get("reason") or "").strip()
  394. if name and note:
  395. reference_notes.append({"element": str(name), "note": note})
  396. point_groups: dict[str, list[str]] = {"灵感点": [], "目的点": [], "关键点": []}
  397. for point in preview.topic.get("points") or []:
  398. point_type = str(point.get("point_type") or "")
  399. point_result = str(point.get("point_result") or "").strip()
  400. if point_type in point_groups and point_result:
  401. point_groups[point_type].append(point_result)
  402. queries_by_need: dict[str, list[str]] = {}
  403. for query in result.selected_queries:
  404. need_keys = query.knowledge_need_keys or [str(query.metadata.get("need_key") or "")]
  405. for need_key in need_keys:
  406. if need_key:
  407. queries_by_need.setdefault(need_key, []).append(query.query_text)
  408. return {
  409. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  410. "source": preview.source,
  411. "topic": {
  412. "status": preview.topic.get("status"),
  413. "result": preview.topic.get("result"),
  414. "topic_direction": preview.topic.get("topic_direction"),
  415. "dimensions": dimensions,
  416. "points": preview.topic.get("points") or [],
  417. "input_groups": {
  418. "points": point_groups,
  419. "reference_notes": reference_notes[:8],
  420. },
  421. },
  422. "knowledge_needs": [
  423. {
  424. "need_key": need.need_key,
  425. "route": need.metadata.get("route"),
  426. "route_label": need.metadata.get("route_label"),
  427. "decision_context": need.decision_context,
  428. "unknown_information": need.unknown_information,
  429. "priority": need.priority,
  430. "source_ref": need.source_ref,
  431. }
  432. for need in result.knowledge_needs
  433. ],
  434. "field_mappings": [
  435. {
  436. "field_key": need.source_ref.get("field_key"),
  437. "field_path": need.source_ref.get("field_path"),
  438. "field_label": need.source_ref.get("field_label"),
  439. "field_value": need.source_ref.get("field_value"),
  440. "judgement": need.decision_context,
  441. "queries": queries_by_need.get(need.need_key, []),
  442. }
  443. for need in result.knowledge_needs
  444. if queries_by_need.get(need.need_key)
  445. ],
  446. "queries": [
  447. {
  448. "query": query.query_text,
  449. "priority": query.priority,
  450. "route": query.metadata.get("route"),
  451. "route_label": query.metadata.get("route_label"),
  452. "need_key": query.metadata.get("need_key"),
  453. "axes": query.axes,
  454. "source_refs": query.source_refs,
  455. }
  456. for query in result.selected_queries
  457. ],
  458. "summary": {
  459. "generated_count": result.stats.generated_count,
  460. "unique_count": result.stats.unique_count,
  461. "selected_count": result.stats.selected_count,
  462. "dropped_count": result.stats.dropped_count,
  463. "route_counts": route_counts,
  464. "persisted": False,
  465. "search_started": False,
  466. },
  467. "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
  468. }