topic_table.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  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_v4"
  13. ROUTES: dict[str, dict[str, Any]] = {
  14. "unique_hook": {
  15. "label": "灵感点",
  16. "source_description": "来自选题的灵感点字段",
  17. "priority": 0,
  18. },
  19. "goal_execution": {
  20. "label": "创作目标",
  21. "source_description": "来自目的点字段",
  22. "priority": 0,
  23. },
  24. "key_execution": {
  25. "label": "关键镜头和动作",
  26. "source_description": "来自关键点字段",
  27. "priority": 0,
  28. },
  29. "cross_dimension": {
  30. "label": "实质、形式、意图及元素关系",
  31. "source_description": "来自组成元素、最终选题描述和元素关系字段",
  32. "priority": 0,
  33. },
  34. "account_fit": {
  35. "label": "账号内容偏好",
  36. "source_description": "来自账号的实质偏好、形式偏好和意图偏好字段",
  37. "priority": 0,
  38. },
  39. "evidence_gap": {
  40. "label": "参考案例和选取理由",
  41. "source_description": "来自每个元素引用的参考来源说明字段",
  42. "priority": 0,
  43. },
  44. }
  45. class TopicTableGenerationError(ValueError):
  46. """Raised when a topic snapshot or LLM result cannot become a Query plan."""
  47. @dataclass(frozen=True)
  48. class TopicTablePreview:
  49. result: GenerationResult
  50. source: dict[str, Any]
  51. topic: dict[str, Any]
  52. field_catalog: list[dict[str, Any]]
  53. llm_output: dict[str, Any]
  54. def topic_table_prompt() -> str:
  55. return load_prompt(PROMPT_NAME)
  56. def _select_topic(payload: dict[str, Any], topic_id: int | None) -> dict[str, Any]:
  57. topics = [topic for topic in payload.get("topics") or [] if isinstance(topic, dict)]
  58. if topic_id is not None:
  59. for topic in topics:
  60. if int(topic.get("id") or 0) == topic_id:
  61. return topic
  62. raise TopicTableGenerationError(f"选题构建中找不到 topic_id={topic_id}")
  63. mature = next((topic for topic in topics if topic.get("status") == "mature"), None)
  64. if mature is not None:
  65. return mature
  66. if topics:
  67. return topics[0]
  68. raise TopicTableGenerationError("选题构建中没有可用 topic")
  69. def _source_summary(source: dict[str, Any]) -> dict[str, Any]:
  70. return {
  71. "derivation_type": source.get("derivation_type"),
  72. "source_type": source.get("source_type"),
  73. "source_reference_id": source.get("source_reference_id"),
  74. "reason": source.get("reason"),
  75. "dataset_from": source.get("dataset_from"),
  76. }
  77. def _compact_snapshot(
  78. payload: dict[str, Any],
  79. topic: dict[str, Any],
  80. ) -> dict[str, Any]:
  81. build = payload.get("build") or {}
  82. items = []
  83. for item in topic.get("composition_items") or []:
  84. if not isinstance(item, dict) or item.get("is_active") is False:
  85. continue
  86. items.append(
  87. {
  88. "id": item.get("id"),
  89. "item_level": item.get("item_level"),
  90. "dimension": item.get("dimension"),
  91. "point_type": item.get("point_type"),
  92. "element_name": item.get("element_name"),
  93. "category_path": item.get("category_path"),
  94. "reason": item.get("reason"),
  95. "is_active": item.get("is_active"),
  96. "sources": [
  97. _source_summary(source)
  98. for source in item.get("sources") or []
  99. if isinstance(source, dict)
  100. ],
  101. }
  102. )
  103. return {
  104. "topic_build_id": build.get("id"),
  105. "topic_id": topic.get("id"),
  106. "build": {
  107. "demand": build.get("demand"),
  108. "demand_constraints": build.get("demand_constraints"),
  109. "strategies_config": build.get("strategies_config"),
  110. "status": build.get("status"),
  111. },
  112. "topic": {
  113. "status": topic.get("status"),
  114. "result": topic.get("result"),
  115. "topic_direction": topic.get("topic_direction"),
  116. "points": [
  117. deepcopy(point)
  118. for point in topic.get("points") or []
  119. if isinstance(point, dict) and point.get("is_active") is not False
  120. ],
  121. "composition_items": items,
  122. "item_relations": deepcopy(topic.get("item_relations") or []),
  123. },
  124. }
  125. def build_topic_table_user_prompt(
  126. snapshot: dict[str, Any],
  127. field_catalog: list[dict[str, Any]],
  128. *,
  129. target_query_count: int,
  130. ) -> str:
  131. return (
  132. "请从 field_catalog 中选择真正能产生创作方法搜索词的具体字段。"
  133. "field_catalog 已经按照六种数据来源标明 source_group_key 和 source_group_label。"
  134. "每一项 field_mapping 只能引用一个真实 field_key;不能合并多个字段,也不能自己编字段。"
  135. "六种数据来源必须全部覆盖,每种来源至少选择一个字段。"
  136. f"所有字段合计生成约 {target_query_count} 条具体搜索词,每个字段生成 1~3 条。"
  137. "judgement 必须是一句不超过 45 个汉字的大白话,只说明大模型针对该字段要判断什么。"
  138. "不要提前编造答案、参数、比例或效果数据,不要启动搜索。\n\n"
  139. + json.dumps(
  140. {
  141. "topic_build_id": snapshot["topic_build_id"],
  142. "topic_id": snapshot["topic_id"],
  143. "field_catalog": field_catalog,
  144. },
  145. ensure_ascii=False,
  146. indent=2,
  147. default=str,
  148. )
  149. )
  150. def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
  151. topic = snapshot["topic"]
  152. topic_id = snapshot["topic_id"]
  153. fields: list[dict[str, Any]] = []
  154. def add(
  155. *,
  156. field_key: str,
  157. field_path: str,
  158. field_label: str,
  159. field_value: Any,
  160. route: str,
  161. point_id: Any = None,
  162. item_ids: list[Any] | None = None,
  163. is_current: bool = True,
  164. ) -> None:
  165. value = str(field_value or "").strip()
  166. if not value:
  167. return
  168. fields.append(
  169. {
  170. "field_key": field_key,
  171. "field_path": field_path,
  172. "field_label": field_label,
  173. "field_value": value,
  174. "source_group_key": route,
  175. "source_group_label": ROUTES[route]["label"],
  176. "point_id": point_id,
  177. "item_ids": list(item_ids or []),
  178. "is_current": is_current,
  179. }
  180. )
  181. add(
  182. field_key="topic.result",
  183. field_path=f"topics[id={topic_id}].result",
  184. field_label="最终选题描述",
  185. field_value=topic.get("result"),
  186. route="cross_dimension",
  187. )
  188. direction = topic.get("topic_direction") or {}
  189. for preference, label in (
  190. ("实质偏好", "账号实质偏好"),
  191. ("形式偏好", "账号形式偏好"),
  192. ("意图偏好", "账号意图偏好"),
  193. ):
  194. add(
  195. field_key=f"topic_direction.persona_dimensions.{preference}",
  196. field_path=f"topics[id={topic_id}].topic_direction.persona_dimensions.{preference}",
  197. field_label=label,
  198. field_value=(direction.get("persona_dimensions") or {}).get(preference),
  199. route="account_fit",
  200. )
  201. point_routes = {
  202. "灵感点": "unique_hook",
  203. "目的点": "goal_execution",
  204. "关键点": "key_execution",
  205. }
  206. for point in topic.get("points") or []:
  207. point_id = point.get("id")
  208. point_type = str(point.get("point_type") or "选题点")
  209. add(
  210. field_key=f"point:{point_id}:point_result",
  211. field_path=f"topics[id={topic_id}].points[id={point_id}].point_result",
  212. field_label=f"{point_type}字段",
  213. field_value=point.get("point_result"),
  214. route=point_routes.get(point_type, "cross_dimension"),
  215. point_id=point_id,
  216. item_ids=list(point.get("item_ids") or []),
  217. )
  218. for item in topic.get("composition_items") or []:
  219. item_id = item.get("id")
  220. dimension = str(item.get("dimension") or "元素")
  221. add(
  222. field_key=f"item:{item_id}:element_name",
  223. field_path=(
  224. f"topics[id={topic_id}].composition_items[id={item_id}].element_name"
  225. ),
  226. field_label=f"{dimension}字段",
  227. field_value=item.get("element_name"),
  228. route="cross_dimension",
  229. item_ids=[item_id],
  230. )
  231. for source_index, source in enumerate(item.get("sources") or []):
  232. add(
  233. field_key=f"item:{item_id}:source:{source_index}:reason",
  234. field_path=(
  235. f"topics[id={topic_id}].composition_items[id={item_id}]"
  236. f".sources[{source_index}].reason"
  237. ),
  238. field_label="参考来源说明",
  239. field_value=source.get("reason"),
  240. route="evidence_gap",
  241. item_ids=[item_id],
  242. )
  243. active_item_ids = {
  244. item.get("id") for item in topic.get("composition_items") or []
  245. }
  246. for relation_index, relation in enumerate(topic.get("item_relations") or []):
  247. relation_item_ids = [
  248. value
  249. for value in (
  250. relation.get("source_item_id"),
  251. relation.get("target_item_id"),
  252. )
  253. if value is not None
  254. ]
  255. add(
  256. field_key=f"relation:{relation_index}:reason",
  257. field_path=f"topics[id={topic_id}].item_relations[{relation_index}].reason",
  258. field_label="元素关系说明",
  259. field_value=relation.get("reason"),
  260. route="cross_dimension",
  261. item_ids=relation_item_ids,
  262. is_current=all(item_id in active_item_ids for item_id in relation_item_ids),
  263. )
  264. return fields
  265. def _safe_need_key(value: Any, route: str, index: int) -> str:
  266. raw = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(value or "").strip()).strip("_")
  267. return raw[:80] or f"{route}_{index + 1}"
  268. def _planning_payload(
  269. llm_output: dict[str, Any],
  270. *,
  271. snapshot: dict[str, Any],
  272. field_catalog: list[dict[str, Any]],
  273. ) -> dict[str, Any]:
  274. raw_mappings = llm_output.get("field_mappings")
  275. if not isinstance(raw_mappings, list) or not raw_mappings:
  276. raise TopicTableGenerationError("大模型没有返回字段与搜索词的对应关系")
  277. topic_build_id = snapshot["topic_build_id"]
  278. topic_id = snapshot["topic_id"]
  279. catalog_by_key = {field["field_key"]: field for field in field_catalog}
  280. needs: list[dict[str, Any]] = []
  281. route_mapping_candidates: dict[str, list[list[dict[str, Any]]]] = {
  282. route: [] for route in ROUTES
  283. }
  284. used_field_keys: set[str] = set()
  285. used_need_keys: set[str] = set()
  286. route_mapping_counts: dict[str, int] = {route: 0 for route in ROUTES}
  287. selected_labels_by_route: dict[str, set[str]] = {
  288. route: set() for route in ROUTES
  289. }
  290. for index, raw in enumerate(raw_mappings):
  291. if not isinstance(raw, dict):
  292. continue
  293. field_key = str(raw.get("field_key") or "").strip()
  294. field = catalog_by_key.get(field_key)
  295. if field is None:
  296. raise TopicTableGenerationError(f"大模型引用了不存在的选题表字段:{field_key}")
  297. if field_key in used_field_keys:
  298. raise TopicTableGenerationError(f"大模型重复引用了同一个选题表字段:{field_key}")
  299. used_field_keys.add(field_key)
  300. judgement = str(raw.get("judgement") or "").strip()
  301. if not judgement:
  302. continue
  303. raw_queries = raw.get("queries") or []
  304. if isinstance(raw_queries, str):
  305. raw_queries = [raw_queries]
  306. query_texts = [
  307. str(query or "").strip()
  308. for query in raw_queries
  309. if str(query or "").strip()
  310. ]
  311. if not query_texts:
  312. continue
  313. route = field["source_group_key"]
  314. route_mapping_counts[route] += 1
  315. selected_labels_by_route[route].add(field["field_label"])
  316. priority = ROUTES[route]["priority"]
  317. need_key = _safe_need_key(field_key, route, index)
  318. if need_key in used_need_keys:
  319. need_key = f"{need_key}_{index + 1}"
  320. used_need_keys.add(need_key)
  321. source_ref = {
  322. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  323. "topic_build_id": topic_build_id,
  324. "topic_id": topic_id,
  325. "route": route,
  326. "field_key": field["field_key"],
  327. "field_path": field["field_path"],
  328. "field_label": field["field_label"],
  329. "field_value": field["field_value"],
  330. "point_id": field.get("point_id"),
  331. "item_ids": list(field.get("item_ids") or []),
  332. }
  333. needs.append(
  334. {
  335. "need_key": need_key,
  336. "decision_context": judgement,
  337. "unknown_information": judgement,
  338. "source_ref": source_ref,
  339. "priority": priority,
  340. "metadata": {
  341. "route": route,
  342. "route_label": ROUTES[route]["label"],
  343. "source_field": field,
  344. },
  345. }
  346. )
  347. mapping_candidates: list[dict[str, Any]] = []
  348. for query_text in query_texts:
  349. mapping_candidates.append(
  350. {
  351. "query_text": query_text,
  352. "axes": {
  353. "选题表字段": field["field_path"],
  354. "字段值": field["field_value"],
  355. "大模型判断": judgement,
  356. },
  357. "priority": priority,
  358. "original_position": 0,
  359. "source_refs": [source_ref],
  360. "knowledge_need_keys": [need_key],
  361. "metadata": {
  362. "family_key": GeneratorKind.TOPIC_TABLE.value,
  363. "route": route,
  364. "route_label": ROUTES[route]["label"],
  365. "need_key": need_key,
  366. },
  367. }
  368. )
  369. route_mapping_candidates[route].append(mapping_candidates)
  370. missing_routes = [
  371. ROUTES[route]["label"]
  372. for route, count in route_mapping_counts.items()
  373. if count == 0
  374. ]
  375. if missing_routes:
  376. raise TopicTableGenerationError(
  377. "大模型没有覆盖全部六种数据来源:" + "、".join(missing_routes)
  378. )
  379. required_dimension_labels = {
  380. field["field_label"]
  381. for field in field_catalog
  382. if field["source_group_key"] == "cross_dimension"
  383. and field["field_label"] in {"实质字段", "形式字段", "意图字段"}
  384. }
  385. missing_dimension_labels = sorted(
  386. required_dimension_labels - selected_labels_by_route["cross_dimension"]
  387. )
  388. if missing_dimension_labels:
  389. raise TopicTableGenerationError(
  390. "组成元素没有同时覆盖实质、形式、意图字段:"
  391. + "、".join(missing_dimension_labels)
  392. )
  393. required_account_labels = {
  394. field["field_label"]
  395. for field in field_catalog
  396. if field["source_group_key"] == "account_fit"
  397. }
  398. missing_account_labels = sorted(
  399. required_account_labels - selected_labels_by_route["account_fit"]
  400. )
  401. if missing_account_labels:
  402. raise TopicTableGenerationError(
  403. "账号内容偏好没有覆盖完整:" + "、".join(missing_account_labels)
  404. )
  405. route_candidates: dict[str, list[dict[str, Any]]] = {
  406. route: [] for route in ROUTES
  407. }
  408. for route, mapping_lists in route_mapping_candidates.items():
  409. max_length = max((len(items) for items in mapping_lists), default=0)
  410. for query_index in range(max_length):
  411. for mapping_candidates in mapping_lists:
  412. if query_index < len(mapping_candidates):
  413. route_candidates[route].append(mapping_candidates[query_index])
  414. candidates: list[dict[str, Any]] = []
  415. position = 0
  416. while any(route_candidates.values()):
  417. for route in ROUTES:
  418. if not route_candidates[route]:
  419. continue
  420. candidate = route_candidates[route].pop(0)
  421. candidate["original_position"] = position
  422. candidates.append(candidate)
  423. position += 1
  424. if not needs or not candidates:
  425. raise TopicTableGenerationError("大模型返回内容无法形成字段与搜索词的对应关系")
  426. return {
  427. "input_snapshot": snapshot,
  428. "knowledge_needs": needs,
  429. "candidates": candidates,
  430. "generator_config": {
  431. "prompt_name": PROMPT_NAME,
  432. "prompt_version": PROMPT_VERSION,
  433. "field_count": len(field_catalog),
  434. },
  435. }
  436. def generate_topic_table_preview(
  437. source_payload: dict[str, Any],
  438. *,
  439. topic_id: int | None,
  440. max_queries: int | None,
  441. chat_fn: Callable[[str, str], dict[str, Any]],
  442. ) -> TopicTablePreview:
  443. topic = _select_topic(source_payload, topic_id)
  444. snapshot = _compact_snapshot(source_payload, topic)
  445. field_catalog = _field_catalog(snapshot)
  446. system_prompt = topic_table_prompt()
  447. user_prompt = build_topic_table_user_prompt(
  448. snapshot,
  449. field_catalog,
  450. target_query_count=max_queries or 18,
  451. )
  452. llm_output: dict[str, Any] = {}
  453. payload: dict[str, Any] | None = None
  454. validation_error: TopicTableGenerationError | None = None
  455. for attempt in range(2):
  456. current_user_prompt = user_prompt
  457. if attempt and validation_error is not None:
  458. current_user_prompt += (
  459. "\n\n上一次输出没有满足字段覆盖要求。"
  460. f"具体问题:{validation_error}。"
  461. "请重新检查六种数据来源、实质/形式/意图字段和三种账号偏好后,"
  462. "重新输出完整 JSON。"
  463. )
  464. candidate_output = chat_fn(system_prompt, current_user_prompt)
  465. if not isinstance(candidate_output, dict):
  466. validation_error = TopicTableGenerationError("大模型返回格式不正确")
  467. continue
  468. llm_output = candidate_output
  469. try:
  470. payload = _planning_payload(
  471. llm_output,
  472. snapshot=snapshot,
  473. field_catalog=field_catalog,
  474. )
  475. break
  476. except TopicTableGenerationError as exc:
  477. validation_error = exc
  478. if payload is None:
  479. raise validation_error or TopicTableGenerationError("大模型返回格式不正确")
  480. request = GenerationRequest(
  481. generator_kind=GeneratorKind.TOPIC_TABLE,
  482. payload=payload,
  483. name=f"topic-table-{snapshot['topic_build_id']}-{snapshot['topic_id']}",
  484. max_queries=max_queries,
  485. metadata={
  486. "source": "pattern_topic_build_api",
  487. "prompt_version": PROMPT_VERSION,
  488. },
  489. )
  490. result = UnifiedQueryGenerationService().generate(request)
  491. return TopicTablePreview(
  492. result=result,
  493. source={
  494. "topic_build_id": snapshot["topic_build_id"],
  495. "topic_id": snapshot["topic_id"],
  496. "source_url": source_payload.get("source_url"),
  497. },
  498. topic=snapshot["topic"],
  499. field_catalog=field_catalog,
  500. llm_output=llm_output,
  501. )
  502. def topic_table_source_preview_to_dict(
  503. source_payload: dict[str, Any],
  504. *,
  505. topic_id: int | None,
  506. ) -> dict[str, Any]:
  507. """Return all six source groups without calling an LLM or generating Query."""
  508. topic = _select_topic(source_payload, topic_id)
  509. snapshot = _compact_snapshot(source_payload, topic)
  510. field_catalog = _field_catalog(snapshot)
  511. source_groups = []
  512. for route, config in ROUTES.items():
  513. fields = [
  514. field
  515. for field in field_catalog
  516. if field["source_group_key"] == route
  517. ]
  518. source_groups.append(
  519. {
  520. "key": route,
  521. "label": config["label"],
  522. "source_description": config["source_description"],
  523. "field_count": len(fields),
  524. "selected_field_count": 0,
  525. "historical_field_count": sum(
  526. 1 for field in fields if not field.get("is_current", True)
  527. ),
  528. "fields": [
  529. {
  530. "field_key": field["field_key"],
  531. "field_path": field["field_path"],
  532. "field_label": field["field_label"],
  533. "field_value": field["field_value"],
  534. "is_current": field.get("is_current", True),
  535. "used_for_query": False,
  536. "judgement": None,
  537. "queries": [],
  538. }
  539. for field in fields
  540. ],
  541. "field_mappings": [],
  542. }
  543. )
  544. return {
  545. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  546. "source": {
  547. "topic_build_id": snapshot["topic_build_id"],
  548. "topic_id": snapshot["topic_id"],
  549. "source_url": source_payload.get("source_url"),
  550. },
  551. "topic": {
  552. "status": snapshot["topic"].get("status"),
  553. "result": snapshot["topic"].get("result"),
  554. },
  555. "source_groups": source_groups,
  556. "field_mappings": [],
  557. "queries": [],
  558. "summary": {
  559. "field_count": len(field_catalog),
  560. "selected_count": 0,
  561. "query_generation_completed": False,
  562. "persisted": False,
  563. "search_started": False,
  564. },
  565. "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
  566. }
  567. def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
  568. result = preview.result
  569. route_counts: dict[str, int] = {route: 0 for route in ROUTES}
  570. for need in result.knowledge_needs:
  571. route = str(need.metadata.get("route") or "")
  572. if route in route_counts:
  573. route_counts[route] += 1
  574. dimensions: dict[str, list[str]] = {"实质": [], "形式": [], "意图": []}
  575. reference_notes: list[dict[str, str]] = []
  576. for item in preview.topic.get("composition_items") or []:
  577. dimension = item.get("dimension")
  578. name = item.get("element_name")
  579. if dimension in dimensions and name and item.get("is_active"):
  580. dimensions[dimension].append(name)
  581. for source in item.get("sources") or []:
  582. note = str(source.get("reason") or item.get("reason") or "").strip()
  583. if name and note:
  584. reference_notes.append({"element": str(name), "note": note})
  585. point_groups: dict[str, list[str]] = {"灵感点": [], "目的点": [], "关键点": []}
  586. for point in preview.topic.get("points") or []:
  587. point_type = str(point.get("point_type") or "")
  588. point_result = str(point.get("point_result") or "").strip()
  589. if point_type in point_groups and point_result:
  590. point_groups[point_type].append(point_result)
  591. queries_by_need: dict[str, list[str]] = {}
  592. for query in result.selected_queries:
  593. need_keys = query.knowledge_need_keys or [str(query.metadata.get("need_key") or "")]
  594. for need_key in need_keys:
  595. if need_key:
  596. queries_by_need.setdefault(need_key, []).append(query.query_text)
  597. selected_by_field_key = {
  598. str(need.source_ref.get("field_key")): {
  599. "judgement": need.decision_context,
  600. "queries": queries_by_need.get(need.need_key, []),
  601. }
  602. for need in result.knowledge_needs
  603. if need.source_ref.get("field_key")
  604. }
  605. return {
  606. "generator_kind": GeneratorKind.TOPIC_TABLE.value,
  607. "source": preview.source,
  608. "topic": {
  609. "status": preview.topic.get("status"),
  610. "result": preview.topic.get("result"),
  611. "topic_direction": preview.topic.get("topic_direction"),
  612. "dimensions": dimensions,
  613. "points": preview.topic.get("points") or [],
  614. "input_groups": {
  615. "points": point_groups,
  616. "reference_notes": reference_notes[:8],
  617. },
  618. },
  619. "knowledge_needs": [
  620. {
  621. "need_key": need.need_key,
  622. "route": need.metadata.get("route"),
  623. "route_label": need.metadata.get("route_label"),
  624. "decision_context": need.decision_context,
  625. "unknown_information": need.unknown_information,
  626. "priority": need.priority,
  627. "source_ref": need.source_ref,
  628. }
  629. for need in result.knowledge_needs
  630. ],
  631. "field_mappings": [
  632. {
  633. "field_key": need.source_ref.get("field_key"),
  634. "field_path": need.source_ref.get("field_path"),
  635. "field_label": need.source_ref.get("field_label"),
  636. "field_value": need.source_ref.get("field_value"),
  637. "judgement": need.decision_context,
  638. "source_group_key": need.metadata.get("route"),
  639. "source_group_label": need.metadata.get("route_label"),
  640. "queries": queries_by_need.get(need.need_key, []),
  641. }
  642. for need in result.knowledge_needs
  643. if queries_by_need.get(need.need_key)
  644. ],
  645. "source_groups": [
  646. {
  647. "key": route,
  648. "label": config["label"],
  649. "source_description": config["source_description"],
  650. "field_count": sum(
  651. 1
  652. for field in preview.field_catalog
  653. if field["source_group_key"] == route
  654. ),
  655. "selected_field_count": sum(
  656. 1
  657. for field in preview.field_catalog
  658. if field["source_group_key"] == route
  659. and field["field_key"] in selected_by_field_key
  660. and selected_by_field_key[field["field_key"]]["queries"]
  661. ),
  662. "historical_field_count": sum(
  663. 1
  664. for field in preview.field_catalog
  665. if field["source_group_key"] == route
  666. and not field.get("is_current", True)
  667. ),
  668. "fields": [
  669. {
  670. "field_key": field["field_key"],
  671. "field_path": field["field_path"],
  672. "field_label": field["field_label"],
  673. "field_value": field["field_value"],
  674. "is_current": field.get("is_current", True),
  675. "used_for_query": bool(
  676. selected_by_field_key.get(field["field_key"], {}).get(
  677. "queries"
  678. )
  679. ),
  680. "judgement": selected_by_field_key.get(
  681. field["field_key"], {}
  682. ).get("judgement"),
  683. "queries": selected_by_field_key.get(
  684. field["field_key"], {}
  685. ).get("queries", []),
  686. }
  687. for field in preview.field_catalog
  688. if field["source_group_key"] == route
  689. ],
  690. "field_mappings": [
  691. {
  692. "field_key": need.source_ref.get("field_key"),
  693. "field_path": need.source_ref.get("field_path"),
  694. "field_label": need.source_ref.get("field_label"),
  695. "field_value": need.source_ref.get("field_value"),
  696. "judgement": need.decision_context,
  697. "queries": queries_by_need.get(need.need_key, []),
  698. }
  699. for need in result.knowledge_needs
  700. if need.metadata.get("route") == route
  701. and queries_by_need.get(need.need_key)
  702. ],
  703. }
  704. for route, config in ROUTES.items()
  705. ],
  706. "queries": [
  707. {
  708. "query": query.query_text,
  709. "priority": query.priority,
  710. "route": query.metadata.get("route"),
  711. "route_label": query.metadata.get("route_label"),
  712. "need_key": query.metadata.get("need_key"),
  713. "axes": query.axes,
  714. "source_refs": query.source_refs,
  715. }
  716. for query in result.selected_queries
  717. ],
  718. "summary": {
  719. "generated_count": result.stats.generated_count,
  720. "unique_count": result.stats.unique_count,
  721. "selected_count": result.stats.selected_count,
  722. "dropped_count": result.stats.dropped_count,
  723. "route_counts": route_counts,
  724. "query_generation_completed": True,
  725. "persisted": False,
  726. "search_started": False,
  727. },
  728. "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
  729. }