瀏覽代碼

Preserve all topic query source groups

SamLee 5 天之前
父節點
當前提交
af27b7d74c

文件差異過大導致無法顯示
+ 0 - 0
app/frontend/dist/assets/index-Bv56Fb5D.css


文件差異過大導致無法顯示
+ 1 - 1
app/frontend/dist/assets/index-Dj16zM9i.js


+ 2 - 2
app/frontend/dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>创作知识</title>
-    <script type="module" crossorigin src="/app/assets/index-BPis2JWQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-DaJoZ67_.css">
+    <script type="module" crossorigin src="/app/assets/index-Dj16zM9i.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-Bv56Fb5D.css">
   </head>
   <body>
     <div id="root"></div>

+ 93 - 38
app/frontend/src/features/query-board/TopicTableQueryPanel.jsx

@@ -6,6 +6,69 @@ const EXAMPLE = {
   topicId: '1392',
 }
 
+const EXAMPLE_GROUPS = [
+  {
+    key: 'unique_hook', label: '灵感点', source_description: '来自选题的灵感点字段',
+    field_mappings: [{
+      field_key: 'point:8371:point_result', field_label: '灵感点字段',
+      field_path: 'topics[id=1392].points[id=8371].point_result',
+      field_value: '在樱花树下低角度仰拍小羊,用CCD记录折射出的彩虹光晕',
+      judgement: '这条灵感里的逆光、动物和低角度画面怎样实际拍出来',
+      queries: ['樱花树下低角度拍小羊怎么安排机位'],
+    }],
+  },
+  {
+    key: 'goal_execution', label: '创作目标', source_description: '来自目的点字段',
+    field_mappings: [{
+      field_key: 'point:8372:point_result', field_label: '目的点字段',
+      field_path: 'topics[id=1392].points[id=8372].point_result', field_value: '传递治愈感',
+      judgement: '治愈感要通过哪些画面和人物状态表现',
+      queries: ['人像摄影怎么通过光线和动作表现治愈感'],
+    }],
+  },
+  {
+    key: 'key_execution', label: '关键镜头和动作', source_description: '来自关键点字段',
+    field_mappings: [{
+      field_key: 'point:8374:point_result', field_label: '关键点字段',
+      field_path: 'topics[id=1392].points[id=8374].point_result', field_value: '逆光氛围感特写',
+      judgement: '逆光特写怎样保住人物细节又有氛围',
+      queries: ['逆光人像特写怎么避免脸部过暗'],
+    }],
+  },
+  {
+    key: 'cross_dimension', label: '实质、形式、意图及元素关系',
+    source_description: '来自组成元素、最终选题描述和元素关系字段',
+    field_mappings: [{
+      field_key: 'item:25686:element_name', field_label: '实质字段',
+      field_path: 'topics[id=1392].composition_items[id=25686].element_name', field_value: '彩虹光晕',
+      judgement: '彩虹光晕怎样在真实拍摄中稳定出现',
+      queries: ['CCD逆光拍摄怎么拍出自然彩虹光晕'],
+    }],
+  },
+  {
+    key: 'account_fit', label: '账号内容偏好',
+    source_description: '来自账号的实质偏好、形式偏好和意图偏好字段',
+    field_mappings: [{
+      field_key: 'topic_direction.persona_dimensions.形式偏好', field_label: '账号形式偏好',
+      field_path: 'topics[id=1392].topic_direction.persona_dimensions.形式偏好',
+      field_value: '低角度仰拍、广角、特写细节、动态摆拍和人景互动',
+      judgement: '这次内容怎样沿用账号常用的镜头和动作',
+      queries: ['穿搭摄影怎么组合低角度全景和细节特写'],
+    }],
+  },
+  {
+    key: 'evidence_gap', label: '参考案例和选取理由',
+    source_description: '来自每个元素引用的参考来源说明字段',
+    field_mappings: [{
+      field_key: 'item:25686:source:0:reason', field_label: '参考来源说明',
+      field_path: 'topics[id=1392].composition_items[id=25686].sources[0].reason',
+      field_value: '案例显示“彩虹印在脸上”能增强画面的惊艳感和新鲜度',
+      judgement: '案例里的彩虹光影换到樱花和小羊场景后怎样调整',
+      queries: ['彩虹投影人像换到户外逆光场景怎么调整'],
+    }],
+  },
+]
+
 function PromptModal({ payload, loading, error, onClose }) {
   return (
     <div className="manual-modal-mask" onClick={onClose}>
@@ -27,7 +90,7 @@ function PromptModal({ payload, loading, error, onClose }) {
 }
 
 function GenerationRows({ result }) {
-  const rows = result.field_mappings || []
+  const groups = result.source_groups || []
 
   return (
     <section className="topic-route-map">
@@ -37,25 +100,34 @@ function GenerationRows({ result }) {
         <strong>生成哪些具体搜索词</strong>
       </div>
       <div className="topic-route-map-body">
-        {rows.map((row) => (
-          <article className="topic-route-row" key={row.field_key}>
-            <div className="topic-route-input">
-              <span>{row.field_label}</span>
-              <code>{row.field_path}</code>
-              <p>{row.field_value}</p>
-            </div>
-            <div className="topic-route-judgement">
-              <p>{row.judgement}</p>
-            </div>
-            <div className="topic-route-queries">
-              {row.queries.map((query, index) => (
-                <div key={`${query}-${index}`}>
-                  <b>{index + 1}</b>
-                  <p>{query}</p>
+        {groups.map((group, groupIndex) => (
+          <section className="topic-source-group" key={group.key}>
+            <header className="topic-source-group-head">
+              <span>第 {groupIndex + 1} 种数据来源</span>
+              <strong>{group.label}</strong>
+              <small>{group.source_description}</small>
+            </header>
+            {(group.field_mappings || []).map((row) => (
+              <article className="topic-route-row" key={row.field_key}>
+                <div className="topic-route-input">
+                  <span>{row.field_label}</span>
+                  <code>{row.field_path}</code>
+                  <p>{row.field_value}</p>
+                </div>
+                <div className="topic-route-judgement">
+                  <p>{row.judgement}</p>
+                </div>
+                <div className="topic-route-queries">
+                  {row.queries.map((query, index) => (
+                    <div key={`${query}-${index}`}>
+                      <b>{index + 1}</b>
+                      <p>{query}</p>
+                    </div>
+                  ))}
                 </div>
-              ))}
-            </div>
-          </article>
+              </article>
+            ))}
+          </section>
         ))}
       </div>
     </section>
@@ -111,7 +183,7 @@ export default function TopicTableQueryPanel() {
         <div>
           <span className="topic-eyebrow">第四种生成方式</span>
           <h2>根据选题表生成搜索词</h2>
-          <p>每一行都是一个真实字段,对应一项大模型判断和由它生成的搜索词。</p>
+          <p>保留六种数据来源;每一种再拆到真实字段,逐字段对应大模型判断和搜索词。</p>
         </div>
         <span className="topic-preview-only">这里只生成搜索词,不会开始搜索</span>
       </header>
@@ -139,24 +211,7 @@ export default function TopicTableQueryPanel() {
 
       {error && <div className="manual-error topic-generation-error">{error}</div>}
       {!result && !loading && (
-        <section className="topic-example-map">
-          <div className="topic-example-cell">
-            <span>实质字段</span>
-            <code>topics[id=1392].composition_items[id=25686].element_name</code>
-            <p>彩虹光晕</p>
-          </div>
-          <div className="topic-example-cell">
-            <span>大模型判断什么</span>
-            <p>这个画面里的彩虹光晕,怎样才能稳定拍出来?</p>
-          </div>
-          <div className="topic-example-cell">
-            <span>生成的搜索词</span>
-            <ul>
-              <li>逆光人像怎么拍出自然彩虹光晕</li>
-              <li>低角度拍摄时太阳和镜头怎么配合</li>
-            </ul>
-          </div>
-        </section>
+        <GenerationRows result={{ source_groups: EXAMPLE_GROUPS }} />
       )}
       {loading && <div className="topic-loading-card"><span />正在读取真实字段,并逐字段生成搜索词…</div>}
       {result && <GenerationRows result={result} />}

+ 29 - 0
app/frontend/src/styles/query-board.css

@@ -807,6 +807,35 @@
   display: grid;
 }
 
+.topic-source-group + .topic-source-group {
+  border-top: 2px solid #d8d1eb;
+}
+
+.topic-source-group-head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  padding: 10px 18px;
+  border-bottom: 1px solid #e5e1ed;
+  background: #f7f4ff;
+}
+
+.topic-source-group-head span {
+  color: #7159c7;
+  font-size: 10px;
+  font-weight: 850;
+}
+
+.topic-source-group-head strong {
+  color: #312a45;
+  font-size: 12px;
+}
+
+.topic-source-group-head small {
+  color: #847d91;
+  font-size: 10px;
+}
+
 .topic-route-row {
   position: relative;
   border-left: 4px solid #7c5ce5;

+ 37 - 17
prompts/topic_table_query_generation.txt

@@ -1,26 +1,45 @@
 你是“选题表字段搜索词生成助手”。
 
-输入里有一份 field_catalog。每一项都是从真实选题表读取出来的字段,包含
+输入里有一份 field_catalog。每一项都是从真实选题表读取的具体字段:
 
-- field_key:程序用来唯一识别字段的键。
-- field_path:字段在选题数据中的真实位置。
-- field_label:业务人员能看懂的字段名称。
-- field_value:这个字段的真实值。
+- field_key:程序识别该字段的唯一键。
+- field_path:该字段在真实选题数据中的位置。
+- field_label:业务人员能看懂的字段名称,例如“实质字段”“形式字段”。
+- field_value:该字段的真实值,例如“彩虹光晕”“低角度仰拍”。
+- source_group_key / source_group_label:这个字段属于下面哪一种数据来源。
 
-你的任务是逐字段判断:这个字段要用于创作时,还需要搜索什么具体方法。然后只为这个字段生成可以直接搜索的短句。
+六种数据来源必须全部保留:
+
+1. unique_hook(灵感点)
+   来自选题的灵感点字段。判断这条灵感具体需要怎么创作出来。
+
+2. goal_execution(创作目标)
+   来自目的点字段。判断“治愈感、好物分享”等目标具体怎样落实到内容里。
+
+3. key_execution(关键镜头和动作)
+   来自关键点字段。判断关键镜头、动作、光线或构图怎样执行。
+
+4. cross_dimension(实质、形式、意图及元素关系)
+   来自每一个具体组成元素、最终选题描述和元素关系字段。必须同时选择至少一个实质字段、一个形式字段和一个意图字段,例如“实质字段=彩虹光晕”“形式字段=低角度仰拍”“意图字段=治愈感”,不能只保留其中一种。
+
+5. account_fit(账号内容偏好)
+   来自账号的实质偏好、形式偏好和意图偏好字段。三个字段必须分别保留,分别判断这次内容怎样沿用账号原来的选材、拍法和表达效果。
+
+6. evidence_gap(参考案例和选取理由)
+   来自每个元素引用的参考来源说明字段。判断旧案例的方法换到当前选题后应该怎样调整。
 
 必须遵守:
 
-1. 每个 field_mapping 只能引用一个 field_key,不能把多个字段合并成一项。
-2. field_key 必须原样来自 field_catalog,不能修改或编造。
-3. 同一个 field_key 不能重复出现。
-4. judgement 用一句不超过 45 个汉字的大白话写清楚针对这个字段要判断什么;不要以“大模型需判断”开头。
-5. 每条搜索词只解决一个具体问题,不能把多个元素和多个判断塞进一句。
-6. 已经写在 field_value 里的事实不能重新当成问题搜索。
-7. 只生成创作方法类搜索词,不生成政策、审核、保险、行业数据、效果数值等事实取证搜索词。
-8. 搜索词里不要写账号名、年份、topic_id、item_id、field_key。
-9. 优先选择能直接影响创作执行的字段。至少覆盖:选题点、实质字段、形式字段、意图字段、账号偏好、参考来源。
-10. 所有字段合计生成约 18 条搜索词,每个字段生成 1~3 条。
+1. 六种数据来源必须全部覆盖,每种来源至少选择一个真实字段,不能只选择实质字段或形式字段
+2. 第 4 种来源必须同时选择实质字段、形式字段和意图字段;第 5 种来源必须同时选择账号实质偏好、账号形式偏好和账号意图偏好
+3. 每个 field_mapping 只能引用一个 field_key,不能把多个字段合并成一项
+4. field_key 必须原样来自 field_catalog,不能修改、重复或编造
+5. judgement 用一句不超过 45 个汉字的大白话写清楚针对这个字段要判断什么
+6. 每条搜索词只解决一个具体问题
+7. 已写在 field_value 里的事实不能重新当成问题搜索
+8. 只生成创作方法类搜索词,不生成政策、审核、保险、行业数据、效果数值等事实取证搜索词
+9. 搜索词里不要写账号名、年份、topic_id、item_id、field_key
+10. 所有字段合计生成约 18 条搜索词。六种数据来源应尽量各生成 3 条;每个具体字段可以生成 1~3 条。
 
 严格输出:
 
@@ -28,7 +47,7 @@
   "field_mappings": [
     {
       "field_key": "必须来自 field_catalog",
-      "judgement": "针对这个字段,大模型要判断什么",
+      "judgement": "针对这个具体字段,大模型要判断什么",
       "queries": [
         "只解决一个问题的具体搜索词"
       ]
@@ -49,6 +68,7 @@
 
 不合格:
 
+- 只从实质字段生成搜索词,漏掉另外五种数据来源。
 - 自己编造 field_key。
 - 把“彩虹光晕、小羊、樱花、穿搭”四个字段合成一项。
 - 只复述字段值,没有说明要判断什么。

+ 174 - 36
query_planning/topic_table.py

@@ -13,15 +13,39 @@ from query_planning.service import UnifiedQueryGenerationService
 
 
 PROMPT_NAME = "topic_table_query_generation"
-PROMPT_VERSION = "topic_table_query_v3"
+PROMPT_VERSION = "topic_table_query_v4"
 
 ROUTES: dict[str, dict[str, Any]] = {
-    "unique_hook": {"label": "灵感点和特殊组合", "priority": 100},
-    "goal_execution": {"label": "目标和想达到的效果", "priority": 90},
-    "key_execution": {"label": "关键镜头和动作要求", "priority": 80},
-    "cross_dimension": {"label": "三类元素怎么放在一起", "priority": 70},
-    "account_fit": {"label": "账号原来的内容风格", "priority": 60},
-    "evidence_gap": {"label": "参考案例哪些地方不能照搬", "priority": 50},
+    "unique_hook": {
+        "label": "灵感点",
+        "source_description": "来自选题的灵感点字段",
+        "priority": 0,
+    },
+    "goal_execution": {
+        "label": "创作目标",
+        "source_description": "来自目的点字段",
+        "priority": 0,
+    },
+    "key_execution": {
+        "label": "关键镜头和动作",
+        "source_description": "来自关键点字段",
+        "priority": 0,
+    },
+    "cross_dimension": {
+        "label": "实质、形式、意图及元素关系",
+        "source_description": "来自组成元素、最终选题描述和元素关系字段",
+        "priority": 0,
+    },
+    "account_fit": {
+        "label": "账号内容偏好",
+        "source_description": "来自账号的实质偏好、形式偏好和意图偏好字段",
+        "priority": 0,
+    },
+    "evidence_gap": {
+        "label": "参考案例和选取理由",
+        "source_description": "来自每个元素引用的参考来源说明字段",
+        "priority": 0,
+    },
 }
 
 
@@ -124,7 +148,9 @@ def build_topic_table_user_prompt(
 ) -> str:
     return (
         "请从 field_catalog 中选择真正能产生创作方法搜索词的具体字段。"
+        "field_catalog 已经按照六种数据来源标明 source_group_key 和 source_group_label。"
         "每一项 field_mapping 只能引用一个真实 field_key;不能合并多个字段,也不能自己编字段。"
+        "六种数据来源必须全部覆盖,每种来源至少选择一个字段。"
         f"所有字段合计生成约 {target_query_count} 条具体搜索词,每个字段生成 1~3 条。"
         "judgement 必须是一句不超过 45 个汉字的大白话,只说明大模型针对该字段要判断什么。"
         "不要提前编造答案、参数、比例或效果数据,不要启动搜索。\n\n"
@@ -165,7 +191,8 @@ def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
                 "field_path": field_path,
                 "field_label": field_label,
                 "field_value": value,
-                "route": route,
+                "source_group_key": route,
+                "source_group_label": ROUTES[route]["label"],
                 "point_id": point_id,
                 "item_ids": list(item_ids or []),
             }
@@ -213,7 +240,6 @@ def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
     for item in topic.get("composition_items") or []:
         item_id = item.get("id")
         dimension = str(item.get("dimension") or "元素")
-        point_type = str(item.get("point_type") or "")
         add(
             field_key=f"item:{item_id}:element_name",
             field_path=(
@@ -221,7 +247,7 @@ def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
             ),
             field_label=f"{dimension}字段",
             field_value=item.get("element_name"),
-            route=point_routes.get(point_type, "cross_dimension"),
+            route="cross_dimension",
             item_ids=[item_id],
         )
         for source_index, source in enumerate(item.get("sources") or []):
@@ -273,9 +299,15 @@ def _planning_payload(
     topic_id = snapshot["topic_id"]
     catalog_by_key = {field["field_key"]: field for field in field_catalog}
     needs: list[dict[str, Any]] = []
-    candidates: list[dict[str, Any]] = []
+    route_mapping_candidates: dict[str, list[list[dict[str, Any]]]] = {
+        route: [] for route in ROUTES
+    }
     used_field_keys: set[str] = set()
-    position = 0
+    used_need_keys: set[str] = set()
+    route_mapping_counts: dict[str, int] = {route: 0 for route in ROUTES}
+    selected_labels_by_route: dict[str, set[str]] = {
+        route: set() for route in ROUTES
+    }
     for index, raw in enumerate(raw_mappings):
         if not isinstance(raw, dict):
             continue
@@ -289,9 +321,24 @@ def _planning_payload(
         judgement = str(raw.get("judgement") or "").strip()
         if not judgement:
             continue
-        route = field["route"]
+        raw_queries = raw.get("queries") or []
+        if isinstance(raw_queries, str):
+            raw_queries = [raw_queries]
+        query_texts = [
+            str(query or "").strip()
+            for query in raw_queries
+            if str(query or "").strip()
+        ]
+        if not query_texts:
+            continue
+        route = field["source_group_key"]
+        route_mapping_counts[route] += 1
+        selected_labels_by_route[route].add(field["field_label"])
         priority = ROUTES[route]["priority"]
         need_key = _safe_need_key(field_key, route, index)
+        if need_key in used_need_keys:
+            need_key = f"{need_key}_{index + 1}"
+        used_need_keys.add(need_key)
         source_ref = {
             "generator_kind": GeneratorKind.TOPIC_TABLE.value,
             "topic_build_id": topic_build_id,
@@ -318,14 +365,9 @@ def _planning_payload(
                 },
             }
         )
-        raw_queries = raw.get("queries") or []
-        if isinstance(raw_queries, str):
-            raw_queries = [raw_queries]
-        for query in raw_queries:
-            query_text = str(query or "").strip()
-            if not query_text:
-                continue
-            candidates.append(
+        mapping_candidates: list[dict[str, Any]] = []
+        for query_text in query_texts:
+            mapping_candidates.append(
                 {
                     "query_text": query_text,
                     "axes": {
@@ -334,7 +376,7 @@ def _planning_payload(
                         "大模型判断": judgement,
                     },
                     "priority": priority,
-                    "original_position": position,
+                    "original_position": 0,
                     "source_refs": [source_ref],
                     "knowledge_need_keys": [need_key],
                     "metadata": {
@@ -345,6 +387,60 @@ def _planning_payload(
                     },
                 }
             )
+        route_mapping_candidates[route].append(mapping_candidates)
+    missing_routes = [
+        ROUTES[route]["label"]
+        for route, count in route_mapping_counts.items()
+        if count == 0
+    ]
+    if missing_routes:
+        raise TopicTableGenerationError(
+            "大模型没有覆盖全部六种数据来源:" + "、".join(missing_routes)
+        )
+    required_dimension_labels = {
+        field["field_label"]
+        for field in field_catalog
+        if field["source_group_key"] == "cross_dimension"
+        and field["field_label"] in {"实质字段", "形式字段", "意图字段"}
+    }
+    missing_dimension_labels = sorted(
+        required_dimension_labels - selected_labels_by_route["cross_dimension"]
+    )
+    if missing_dimension_labels:
+        raise TopicTableGenerationError(
+            "组成元素没有同时覆盖实质、形式、意图字段:"
+            + "、".join(missing_dimension_labels)
+        )
+    required_account_labels = {
+        field["field_label"]
+        for field in field_catalog
+        if field["source_group_key"] == "account_fit"
+    }
+    missing_account_labels = sorted(
+        required_account_labels - selected_labels_by_route["account_fit"]
+    )
+    if missing_account_labels:
+        raise TopicTableGenerationError(
+            "账号内容偏好没有覆盖完整:" + "、".join(missing_account_labels)
+        )
+    route_candidates: dict[str, list[dict[str, Any]]] = {
+        route: [] for route in ROUTES
+    }
+    for route, mapping_lists in route_mapping_candidates.items():
+        max_length = max((len(items) for items in mapping_lists), default=0)
+        for query_index in range(max_length):
+            for mapping_candidates in mapping_lists:
+                if query_index < len(mapping_candidates):
+                    route_candidates[route].append(mapping_candidates[query_index])
+    candidates: list[dict[str, Any]] = []
+    position = 0
+    while any(route_candidates.values()):
+        for route in ROUTES:
+            if not route_candidates[route]:
+                continue
+            candidate = route_candidates[route].pop(0)
+            candidate["original_position"] = position
+            candidates.append(candidate)
             position += 1
     if not needs or not candidates:
         raise TopicTableGenerationError("大模型返回内容无法形成字段与搜索词的对应关系")
@@ -370,21 +466,40 @@ def generate_topic_table_preview(
     topic = _select_topic(source_payload, topic_id)
     snapshot = _compact_snapshot(source_payload, topic)
     field_catalog = _field_catalog(snapshot)
-    llm_output = chat_fn(
-        topic_table_prompt(),
-        build_topic_table_user_prompt(
-            snapshot,
-            field_catalog,
-            target_query_count=max_queries or 18,
-        ),
-    )
-    if not isinstance(llm_output, dict):
-        raise TopicTableGenerationError("大模型返回格式不正确")
-    payload = _planning_payload(
-        llm_output,
-        snapshot=snapshot,
-        field_catalog=field_catalog,
+    system_prompt = topic_table_prompt()
+    user_prompt = build_topic_table_user_prompt(
+        snapshot,
+        field_catalog,
+        target_query_count=max_queries or 18,
     )
+    llm_output: dict[str, Any] = {}
+    payload: dict[str, Any] | None = None
+    validation_error: TopicTableGenerationError | None = None
+    for attempt in range(2):
+        current_user_prompt = user_prompt
+        if attempt and validation_error is not None:
+            current_user_prompt += (
+                "\n\n上一次输出没有满足字段覆盖要求。"
+                f"具体问题:{validation_error}。"
+                "请重新检查六种数据来源、实质/形式/意图字段和三种账号偏好后,"
+                "重新输出完整 JSON。"
+            )
+        candidate_output = chat_fn(system_prompt, current_user_prompt)
+        if not isinstance(candidate_output, dict):
+            validation_error = TopicTableGenerationError("大模型返回格式不正确")
+            continue
+        llm_output = candidate_output
+        try:
+            payload = _planning_payload(
+                llm_output,
+                snapshot=snapshot,
+                field_catalog=field_catalog,
+            )
+            break
+        except TopicTableGenerationError as exc:
+            validation_error = exc
+    if payload is None:
+        raise validation_error or TopicTableGenerationError("大模型返回格式不正确")
     request = GenerationRequest(
         generator_kind=GeneratorKind.TOPIC_TABLE,
         payload=payload,
@@ -471,11 +586,34 @@ def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
                 "field_label": need.source_ref.get("field_label"),
                 "field_value": need.source_ref.get("field_value"),
                 "judgement": need.decision_context,
+                "source_group_key": need.metadata.get("route"),
+                "source_group_label": need.metadata.get("route_label"),
                 "queries": queries_by_need.get(need.need_key, []),
             }
             for need in result.knowledge_needs
             if queries_by_need.get(need.need_key)
         ],
+        "source_groups": [
+            {
+                "key": route,
+                "label": config["label"],
+                "source_description": config["source_description"],
+                "field_mappings": [
+                    {
+                        "field_key": need.source_ref.get("field_key"),
+                        "field_path": need.source_ref.get("field_path"),
+                        "field_label": need.source_ref.get("field_label"),
+                        "field_value": need.source_ref.get("field_value"),
+                        "judgement": need.decision_context,
+                        "queries": queries_by_need.get(need.need_key, []),
+                    }
+                    for need in result.knowledge_needs
+                    if need.metadata.get("route") == route
+                    and queries_by_need.get(need.need_key)
+                ],
+            }
+            for route, config in ROUTES.items()
+        ],
         "queries": [
             {
                 "query": query.query_text,

+ 56 - 3
tests/test_topic_table_query_generation.py

@@ -50,7 +50,21 @@ def _topic_payload() -> dict[str, Any]:
                         "point_result": "低角度仰拍小羊和彩虹光晕",
                         "is_active": True,
                         "item_ids": [25684, 25688],
-                    }
+                    },
+                    {
+                        "id": 8372,
+                        "point_type": "目的点",
+                        "point_result": "传递治愈感",
+                        "is_active": True,
+                        "item_ids": [25700],
+                    },
+                    {
+                        "id": 8374,
+                        "point_type": "关键点",
+                        "point_result": "逆光氛围感特写",
+                        "is_active": True,
+                        "item_ids": [25684],
+                    },
                 ],
                 "composition_items": [
                     {
@@ -109,10 +123,14 @@ def _topic_payload() -> dict[str, Any]:
 def _llm_output() -> dict[str, Any]:
     fields = [
         ("point:8371:point_result", "灵感点怎么拍出来"),
-        ("item:25684:element_name", "低角度仰拍怎么执行"),
+        ("point:8372:point_result", "治愈感怎么落实到画面"),
+        ("point:8374:point_result", "逆光特写怎么执行"),
         ("item:25688:element_name", "小羊怎么自然入镜"),
+        ("item:25684:element_name", "低角度仰拍怎么执行"),
         ("item:25700:element_name", "治愈感怎么通过画面表现"),
+        ("topic_direction.persona_dimensions.实质偏好", "怎么保持账号原来的选材"),
         ("topic_direction.persona_dimensions.形式偏好", "怎么保持账号原来的拍法"),
+        ("topic_direction.persona_dimensions.意图偏好", "怎么保持账号原来的表达效果"),
         ("item:25684:source:0:reason", "旧案例换场景后怎么调整"),
     ]
     return {
@@ -171,11 +189,13 @@ def test_topic_table_generation_maps_real_fields_and_uses_unified_budget():
     data = preview_to_dict(preview)
 
     assert data["generator_kind"] == "topic_table"
-    assert len(data["knowledge_needs"]) == 6
+    assert len(data["knowledge_needs"]) == 10
     assert set(data["summary"]["route_counts"]) == set(ROUTES)
     assert data["summary"]["selected_count"] == 8
     assert data["summary"]["persisted"] is False
     assert data["summary"]["search_started"] is False
+    assert len(data["source_groups"]) == 6
+    assert all(group["field_mappings"] for group in data["source_groups"])
     assert data["topic"]["dimensions"] == {
         "实质": ["羊"],
         "形式": ["低角度仰拍"],
@@ -198,6 +218,8 @@ def test_topic_table_generation_maps_real_fields_and_uses_unified_budget():
         "field_label": "灵感点字段",
         "field_value": "低角度仰拍小羊和彩虹光晕",
         "judgement": "灵感点怎么拍出来",
+        "source_group_key": "unique_hook",
+        "source_group_label": "灵感点",
         "queries": [
             "灵感点怎么拍出来有哪些方法",
             "逆光人像怎么拍出彩虹光晕",
@@ -233,6 +255,37 @@ def test_topic_table_generation_rejects_duplicate_field_key():
         )
 
 
+def test_topic_table_generation_rejects_missing_source_group():
+    malformed = _llm_output()
+    malformed["field_mappings"] = malformed["field_mappings"][:-1]
+
+    with pytest.raises(TopicTableGenerationError, match="全部六种数据来源"):
+        generate_topic_table_preview(
+            _topic_payload(),
+            topic_id=1392,
+            max_queries=18,
+            chat_fn=lambda _system, _user: malformed,
+        )
+
+
+def test_topic_table_generation_retries_once_when_a_source_group_is_missing():
+    malformed = _llm_output()
+    malformed["field_mappings"] = malformed["field_mappings"][:-1]
+    outputs = iter([malformed, _llm_output()])
+    prompts = []
+
+    preview = generate_topic_table_preview(
+        _topic_payload(),
+        topic_id=1392,
+        max_queries=18,
+        chat_fn=lambda _system, user: prompts.append(user) or next(outputs),
+    )
+
+    assert len(preview.result.selected_queries) == 18
+    assert len(prompts) == 2
+    assert "上一次输出没有满足字段覆盖要求" in prompts[1]
+
+
 def test_topic_table_prompt_and_preview_api_do_not_persist_or_search(monkeypatch):
     class Source:
         def fetch(self, topic_build_id):

部分文件因文件數量過多而無法顯示