Selaa lähdekoodia

Map topic queries to exact source fields

SamLee 6 päivää sitten
vanhempi
commit
3dfc7e4213

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 8 - 0
app/frontend/dist/assets/index-BPis2JWQ.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 8
app/frontend/dist/assets/index-CRU6BH8R.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
app/frontend/dist/assets/index-DaJoZ67_.css


+ 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-CRU6BH8R.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-C-ON6ep2.css">
+    <script type="module" crossorigin src="/app/assets/index-BPis2JWQ.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-DaJoZ67_.css">
   </head>
   <body>
     <div id="root"></div>

+ 15 - 95
app/frontend/src/features/query-board/TopicTableQueryPanel.jsx

@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react'
+import { useState } from 'react'
 import { generateTopicTableQueries, getTopicTableQueryPrompt } from '../../api/workbench.js'
 
 const EXAMPLE = {
@@ -6,70 +6,6 @@ const EXAMPLE = {
   topicId: '1392',
 }
 
-const ROUTES = [
-  { key: 'unique_hook', inputTitle: '灵感点和特殊组合' },
-  { key: 'goal_execution', inputTitle: '目标和想达到的效果' },
-  { key: 'key_execution', inputTitle: '关键镜头和动作要求' },
-  { key: 'cross_dimension', inputTitle: '实质、形式、意图三类元素' },
-  { key: 'account_fit', inputTitle: '账号原来的内容风格' },
-  { key: 'evidence_gap', inputTitle: '参考案例和元素来源' },
-]
-
-function shortText(value, max = 120) {
-  const text = String(value || '').trim()
-  return text.length > max ? `${text.slice(0, max)}…` : text
-}
-
-function firstItems(values, max = 4) {
-  return [...new Set((values || []).filter(Boolean).map((value) => shortText(value)))].slice(0, max)
-}
-
-function joinNames(values, max = 8) {
-  const names = values || []
-  if (names.length <= max) return names.join('、')
-  return `${names.slice(0, max).join('、')}等`
-}
-
-function sourceItems(result, route) {
-  const topic = result.topic || {}
-  const groups = topic.input_groups || {}
-  const points = groups.points || {}
-  const dimensions = topic.dimensions || {}
-  const direction = topic.topic_direction || {}
-  const preferences = direction.persona_dimensions || {}
-
-  if (route === 'unique_hook') {
-    return firstItems(points['灵感点'] || [topic.result])
-  }
-  if (route === 'goal_execution') {
-    return firstItems([
-      ...(points['目的点'] || []),
-      dimensions['意图']?.length ? `想达到的效果:${joinNames(dimensions['意图'])}` : '',
-    ])
-  }
-  if (route === 'key_execution') {
-    return firstItems([
-      ...(points['关键点'] || []),
-      dimensions['形式']?.length ? `拍摄形式:${joinNames(dimensions['形式'])}` : '',
-    ])
-  }
-  if (route === 'cross_dimension') {
-    return firstItems([
-      dimensions['实质']?.length ? `内容里有什么:${joinNames(dimensions['实质'])}` : '',
-      dimensions['形式']?.length ? `怎么呈现:${joinNames(dimensions['形式'])}` : '',
-      dimensions['意图']?.length ? `想达到什么效果:${joinNames(dimensions['意图'])}` : '',
-    ])
-  }
-  if (route === 'account_fit') {
-    return firstItems([
-      preferences['实质偏好'] ? `常拍内容:${preferences['实质偏好']}` : '',
-      preferences['形式偏好'] ? `常用拍法:${preferences['形式偏好']}` : '',
-      preferences['意图偏好'] ? `希望观众感受到:${preferences['意图偏好']}` : '',
-    ], 3)
-  }
-  return firstItems((groups.reference_notes || []).map((row) => `${row.element}:${row.note}`), 3)
-}
-
 function PromptModal({ payload, loading, error, onClose }) {
   return (
     <div className="manual-modal-mask" onClick={onClose}>
@@ -84,29 +20,14 @@ function PromptModal({ payload, loading, error, onClose }) {
         </header>
         {loading && <div className="topic-inline-state">正在读取 Prompt…</div>}
         {error && <div className="manual-error">{error}</div>}
-        {payload && (
-          <>
-            <div className="topic-route-strip">
-              {(payload.routes || []).map((route) => (
-                <span key={route.key}>{route.label}</span>
-              ))}
-            </div>
-            <pre className="topic-prompt-code">{payload.system_prompt}</pre>
-          </>
-        )}
+        {payload && <pre className="topic-prompt-code">{payload.system_prompt}</pre>}
       </section>
     </div>
   )
 }
 
 function GenerationRows({ result }) {
-  const rows = useMemo(() => ROUTES.map((route, index) => ({
-    ...route,
-    number: index + 1,
-    need: (result.knowledge_needs || []).find((need) => need.route === route.key),
-    queries: (result.queries || []).filter((query) => query.route === route.key),
-    inputs: sourceItems(result, route.key),
-  })), [result])
+  const rows = result.field_mappings || []
 
   return (
     <section className="topic-route-map">
@@ -117,22 +38,20 @@ function GenerationRows({ result }) {
       </div>
       <div className="topic-route-map-body">
         {rows.map((row) => (
-          <article className={`topic-route-row route-${row.key}`} key={row.key}>
+          <article className="topic-route-row" key={row.field_key}>
             <div className="topic-route-input">
-              <span>第 {row.number} 种</span>
-              <h3>{row.inputTitle}</h3>
-              <ul>
-                {row.inputs.map((item) => <li key={item}>{item}</li>)}
-              </ul>
+              <span>{row.field_label}</span>
+              <code>{row.field_path}</code>
+              <p>{row.field_value}</p>
             </div>
             <div className="topic-route-judgement">
-              <p>{row.need?.decision_context || '这类信息没有形成需要判断的问题'}</p>
+              <p>{row.judgement}</p>
             </div>
             <div className="topic-route-queries">
               {row.queries.map((query, index) => (
-                <div key={`${query.query}-${index}`}>
+                <div key={`${query}-${index}`}>
                   <b>{index + 1}</b>
-                  <p>{query.query}</p>
+                  <p>{query}</p>
                 </div>
               ))}
             </div>
@@ -192,7 +111,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>
@@ -222,8 +141,9 @@ export default function TopicTableQueryPanel() {
       {!result && !loading && (
         <section className="topic-example-map">
           <div className="topic-example-cell">
-            <span>第 1 种 · 灵感点和特殊组合</span>
-            <p>樱花树下低角度拍小羊,用 CCD 记录彩虹光晕</p>
+            <span>实质字段</span>
+            <code>topics[id=1392].composition_items[id=25686].element_name</code>
+            <p>彩虹光晕</p>
           </div>
           <div className="topic-example-cell">
             <span>大模型判断什么</span>
@@ -238,7 +158,7 @@ export default function TopicTableQueryPanel() {
           </div>
         </section>
       )}
-      {loading && <div className="topic-loading-card"><span />正在读取选题表,并逐类生成搜索词…</div>}
+      {loading && <div className="topic-loading-card"><span />正在读取真实字段,并逐字段生成搜索词…</div>}
       {result && <GenerationRows result={result} />}
       {promptOpen && (
         <PromptModal

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

@@ -766,6 +766,15 @@
   margin: 8px 0 0;
 }
 
+.topic-example-cell code,
+.topic-route-input code {
+  display: block;
+  margin-top: 8px;
+  color: #7a718a;
+  font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  overflow-wrap: anywhere;
+}
+
 .topic-example-cell ul {
   margin: 7px 0 0;
   padding-left: 18px;
@@ -830,6 +839,14 @@
   font-weight: 850;
 }
 
+.topic-route-input > p {
+  margin: 9px 0 0;
+  color: #332e3f;
+  font-size: 12px;
+  font-weight: 650;
+  line-height: 1.55;
+}
+
 .topic-route-input h3 {
   margin: 8px 0 9px;
   color: #29243a;

+ 37 - 58
prompts/topic_table_query_generation.txt

@@ -1,59 +1,34 @@
-你是“选题表搜索词生成助手”。输入是一条已经定稿或接近定稿的选题,以及它的账号人设、选题点、实质/形式/意图元素、元素关系和已有来源。
+你是“选题表字段搜索词生成助手”。
 
-你的任务不是复述选题,也不是判断外部事实真假,而是识别:为了把这个选题真正创作出来,创作者还需要搜索哪些具体问题,并把每个问题拆成一条条可以直接搜索的短句。
+输入里有一份 field_catalog。每一项都是从真实选题表读取出来的字段,包含:
 
-必须从下面六类信息逐项检查,每类信息输出一个需要搜索的问题:
+- field_key:程序用来唯一识别字段的键。
+- field_path:字段在选题数据中的真实位置。
+- field_label:业务人员能看懂的字段名称。
+- field_value:这个字段的真实值。
 
-1. unique_hook(灵感点和特殊组合)
-   看灵感点、特殊组合和容易被记住的元素,判断这些画面具体要怎么拍出来。
+你的任务是逐字段判断:这个字段要用于创作时,还需要搜索什么具体方法。然后只为这个字段生成可以直接搜索的短句。
 
-2. goal_execution(目标和想达到的效果)
-   看目的点、意图和最终选题描述,判断治愈、种草、让人记住等效果具体怎么做出来。
+必须遵守:
 
-3. key_execution(关键镜头和动作要求)
-   看关键点和拍摄形式,判断镜头、动作、结构、光线、构图具体怎么执行。
+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 条。
 
-4. cross_dimension(三类元素怎么放在一起)
-   把实质、形式和意图三类元素放在一起看,判断哪些应该突出、哪些应该弱化、先后怎么安排。
-
-5. account_fit(账号原来的内容风格)
-   看账号原来常拍什么、怎么拍、想让观众产生什么感受,判断这次内容怎么保持同一种风格。
-
-6. evidence_gap(参考案例哪些地方不能照搬)
-   看参考案例、元素关系和选择理由。旧案例换到当前场景后可能失效,需要搜索怎么调整、哪里容易拍坏。
-
-判断规则:
-
-- decision_context 用一句不超过 45 个汉字的大白话写“大模型要判断什么”,不要先解释背景;unknown_information 只供程序记录,用大白话写还需要查清楚什么。不要使用内部术语。
-- 已经在选题表里明确决定的对象、场景和目标,不要当成未知信息重复搜索。
-- 一条搜索词只解决一个问题,不要把场景、穿搭、拍摄、情绪、结构全部塞进一句。
-- 搜索词应使用真实创作者会搜索的自然语言,避免空泛词和数据库字段名。
-- 优先搜索可迁移的创作方法、组织方式、决策依据和失败规避;不要只搜索这个具体选题标题。
-- 只生成创作方法类搜索词,不要生成政策法规、平台审核、保险报备、科学事实、行业数据、效果数值等事实取证搜索词。
-- 不要在搜索词中写具体账号名、年份、topic_id、item_id,也不要假设存在精确的色彩阈值或情绪数值。
-- account_fit 要把账号偏好转译成可迁移的视觉风格、内容结构或种草方法,不要搜索具体账号自身。
-- evidence_gap 要搜索旧案例迁移到新场景时怎么调整、哪里容易失败,不要搜索外部合规规则。
-- knowledge_needs 必须正好输出 6 项:六类信息各 1 项,不多不少。不要为了凑搜索词总数,把同一类信息拆成多个问题。
-- 每个问题默认产出 2~3 条互不重复的具体搜索词;搜索词数量放在 queries 内,不是增加问题数量。
-- decision_context 只描述“要做的创作决定”;unknown_information 只概括“还缺哪类信息”。二者都不能提前编造机位厘米数、镜头参数、比例、阈值、转化率或所谓研究结论。
-- 意思相近但问法不同的搜索词可以保留,完全重复的搜索词不要重复输出。
-- source_ref 只引用输入中真实存在的 topic_build_id、topic_id、point_id、item_ids;不要编造 ID。
-- route 只能使用六个固定值之一:unique_hook、goal_execution、key_execution、cross_dimension、account_fit、evidence_gap。
-
-严格输出以下 JSON 对象:
+严格输出:
 
 {
-  "knowledge_needs": [
+  "field_mappings": [
     {
-      "need_key": "稳定且简短的英文键",
-      "route": "六个固定路径之一",
-      "decision_context": "当前要做的创作决定",
-      "unknown_information": "为了做出这个决定,真正缺少的信息",
-      "priority": 0,
-      "source_ref": {
-        "point_id": null,
-        "item_ids": []
-      },
+      "field_key": "必须来自 field_catalog",
+      "judgement": "针对这个字段,大模型要判断什么",
       "queries": [
         "只解决一个问题的具体搜索词"
       ]
@@ -61,16 +36,20 @@
   ]
 }
 
-priority 建议范围为 0~100:灵感点和目标最高,关键镜头和三类元素组合其次,账号风格与参考案例根据实际风险排序。
+合格例子:
+
+{
+  "field_key": "item:25686:element_name",
+  "judgement": "彩虹光晕怎样在真实拍摄中稳定出现",
+  "queries": [
+    "CCD逆光拍摄怎么拍出自然彩虹光晕",
+    "低角度拍摄时太阳和镜头怎么配合"
+  ]
+}
 
-合格示例:
-- 逆光人像怎么拍出自然彩虹光晕
-- 人物和小羊互动怎么拍得自然
-- 氛围感穿搭图文如何自然展示配饰
-- 低角度人像如何避免人物比例变形
+不合格:
 
-不合格示例:
-- 小红书动物内容审核规则
-- 某某账号爆款照片数据分析
-- 2024年户外拍摄保险要求
-- 樱花小羊CCD治愈穿搭怎么拍(一次混入太多判断)
+- 自己编造 field_key。
+- 把“彩虹光晕、小羊、樱花、穿搭”四个字段合成一项。
+- 只复述字段值,没有说明要判断什么。
+- 一条搜索词同时询问光线、构图、穿搭、情绪和平台规则。

+ 193 - 47
query_planning/topic_table.py

@@ -13,7 +13,7 @@ from query_planning.service import UnifiedQueryGenerationService
 
 
 PROMPT_NAME = "topic_table_query_generation"
-PROMPT_VERSION = "topic_table_query_v2"
+PROMPT_VERSION = "topic_table_query_v3"
 
 ROUTES: dict[str, dict[str, Any]] = {
     "unique_hook": {"label": "灵感点和特殊组合", "priority": 100},
@@ -118,20 +118,143 @@ def _compact_snapshot(
 
 def build_topic_table_user_prompt(
     snapshot: dict[str, Any],
+    field_catalog: list[dict[str, Any]],
     *,
     target_query_count: int,
 ) -> str:
     return (
-        "请根据下面的真实选题表快照,从六类信息中各判断一个需要搜索的问题。"
-        "knowledge_needs 数组必须正好有 6 项,每类信息正好 1 项,不能把搜索词数量误当成问题数量。"
-        f"六个问题合计生成约 {target_query_count} 条具体搜索词;数量允许时,每个问题给 2~3 条。"
-        "decision_context 必须是一句不超过 45 个汉字的大白话,只说大模型要判断什么,不要先解释背景。"
-        "只输出待搜索的问题,不要在 decision_context 或 unknown_information 中提前编造答案、参数、比例或效果数据。"
-        "不要启动搜索。\n\n"
-        + json.dumps(snapshot, ensure_ascii=False, indent=2, default=str)
+        "请从 field_catalog 中选择真正能产生创作方法搜索词的具体字段。"
+        "每一项 field_mapping 只能引用一个真实 field_key;不能合并多个字段,也不能自己编字段。"
+        f"所有字段合计生成约 {target_query_count} 条具体搜索词,每个字段生成 1~3 条。"
+        "judgement 必须是一句不超过 45 个汉字的大白话,只说明大模型针对该字段要判断什么。"
+        "不要提前编造答案、参数、比例或效果数据,不要启动搜索。\n\n"
+        + json.dumps(
+            {
+                "topic_build_id": snapshot["topic_build_id"],
+                "topic_id": snapshot["topic_id"],
+                "field_catalog": field_catalog,
+            },
+            ensure_ascii=False,
+            indent=2,
+            default=str,
+        )
     )
 
 
+def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
+    topic = snapshot["topic"]
+    topic_id = snapshot["topic_id"]
+    fields: list[dict[str, Any]] = []
+
+    def add(
+        *,
+        field_key: str,
+        field_path: str,
+        field_label: str,
+        field_value: Any,
+        route: str,
+        point_id: Any = None,
+        item_ids: list[Any] | None = None,
+    ) -> None:
+        value = str(field_value or "").strip()
+        if not value:
+            return
+        fields.append(
+            {
+                "field_key": field_key,
+                "field_path": field_path,
+                "field_label": field_label,
+                "field_value": value,
+                "route": route,
+                "point_id": point_id,
+                "item_ids": list(item_ids or []),
+            }
+        )
+
+    add(
+        field_key="topic.result",
+        field_path=f"topics[id={topic_id}].result",
+        field_label="最终选题描述",
+        field_value=topic.get("result"),
+        route="cross_dimension",
+    )
+    direction = topic.get("topic_direction") or {}
+    for preference, label in (
+        ("实质偏好", "账号实质偏好"),
+        ("形式偏好", "账号形式偏好"),
+        ("意图偏好", "账号意图偏好"),
+    ):
+        add(
+            field_key=f"topic_direction.persona_dimensions.{preference}",
+            field_path=f"topics[id={topic_id}].topic_direction.persona_dimensions.{preference}",
+            field_label=label,
+            field_value=(direction.get("persona_dimensions") or {}).get(preference),
+            route="account_fit",
+        )
+
+    point_routes = {
+        "灵感点": "unique_hook",
+        "目的点": "goal_execution",
+        "关键点": "key_execution",
+    }
+    for point in topic.get("points") or []:
+        point_id = point.get("id")
+        point_type = str(point.get("point_type") or "选题点")
+        add(
+            field_key=f"point:{point_id}:point_result",
+            field_path=f"topics[id={topic_id}].points[id={point_id}].point_result",
+            field_label=f"{point_type}字段",
+            field_value=point.get("point_result"),
+            route=point_routes.get(point_type, "cross_dimension"),
+            point_id=point_id,
+            item_ids=list(point.get("item_ids") or []),
+        )
+
+    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=(
+                f"topics[id={topic_id}].composition_items[id={item_id}].element_name"
+            ),
+            field_label=f"{dimension}字段",
+            field_value=item.get("element_name"),
+            route=point_routes.get(point_type, "cross_dimension"),
+            item_ids=[item_id],
+        )
+        for source_index, source in enumerate(item.get("sources") or []):
+            add(
+                field_key=f"item:{item_id}:source:{source_index}:reason",
+                field_path=(
+                    f"topics[id={topic_id}].composition_items[id={item_id}]"
+                    f".sources[{source_index}].reason"
+                ),
+                field_label="参考来源说明",
+                field_value=source.get("reason"),
+                route="evidence_gap",
+                item_ids=[item_id],
+            )
+    for relation_index, relation in enumerate(topic.get("item_relations") or []):
+        add(
+            field_key=f"relation:{relation_index}:reason",
+            field_path=f"topics[id={topic_id}].item_relations[{relation_index}].reason",
+            field_label="元素关系说明",
+            field_value=relation.get("reason"),
+            route="cross_dimension",
+            item_ids=[
+                value
+                for value in (
+                    relation.get("source_item_id"),
+                    relation.get("target_item_id"),
+                )
+                if value is not None
+            ],
+        )
+    return fields
+
+
 def _safe_need_key(value: Any, route: str, index: int) -> str:
     raw = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(value or "").strip()).strip("_")
     return raw[:80] or f"{route}_{index + 1}"
@@ -141,50 +264,58 @@ def _planning_payload(
     llm_output: dict[str, Any],
     *,
     snapshot: dict[str, Any],
+    field_catalog: list[dict[str, Any]],
 ) -> dict[str, Any]:
-    raw_needs = llm_output.get("knowledge_needs")
-    if not isinstance(raw_needs, list) or not raw_needs:
-        raise TopicTableGenerationError("大模型没有返回六类搜索问题")
+    raw_mappings = llm_output.get("field_mappings")
+    if not isinstance(raw_mappings, list) or not raw_mappings:
+        raise TopicTableGenerationError("大模型没有返回字段与搜索词的对应关系")
     topic_build_id = snapshot["topic_build_id"]
     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]] = []
-    used_keys: set[str] = set()
-    route_counts: dict[str, int] = {route: 0 for route in ROUTES}
+    used_field_keys: set[str] = set()
     position = 0
-    for index, raw in enumerate(raw_needs):
+    for index, raw in enumerate(raw_mappings):
         if not isinstance(raw, dict):
             continue
-        route = str(raw.get("route") or "").strip()
-        if route not in ROUTES:
+        field_key = str(raw.get("field_key") or "").strip()
+        field = catalog_by_key.get(field_key)
+        if field is None:
+            raise TopicTableGenerationError(f"大模型引用了不存在的选题表字段:{field_key}")
+        if field_key in used_field_keys:
+            raise TopicTableGenerationError(f"大模型重复引用了同一个选题表字段:{field_key}")
+        used_field_keys.add(field_key)
+        judgement = str(raw.get("judgement") or "").strip()
+        if not judgement:
             continue
-        route_counts[route] += 1
-        need_key = _safe_need_key(raw.get("need_key"), route, index)
-        if need_key in used_keys:
-            need_key = f"{need_key}_{index + 1}"
-        used_keys.add(need_key)
-        decision_context = str(raw.get("decision_context") or "").strip()
-        unknown_information = str(raw.get("unknown_information") or "").strip()
-        if not decision_context or not unknown_information:
-            continue
-        priority = max(0, min(100, int(raw.get("priority") or ROUTES[route]["priority"])))
-        raw_ref = raw.get("source_ref") if isinstance(raw.get("source_ref"), dict) else {}
+        route = field["route"]
+        priority = ROUTES[route]["priority"]
+        need_key = _safe_need_key(field_key, route, index)
         source_ref = {
             "generator_kind": GeneratorKind.TOPIC_TABLE.value,
             "topic_build_id": topic_build_id,
             "topic_id": topic_id,
             "route": route,
-            "point_id": raw_ref.get("point_id"),
-            "item_ids": list(raw_ref.get("item_ids") or []),
+            "field_key": field["field_key"],
+            "field_path": field["field_path"],
+            "field_label": field["field_label"],
+            "field_value": field["field_value"],
+            "point_id": field.get("point_id"),
+            "item_ids": list(field.get("item_ids") or []),
         }
         needs.append(
             {
                 "need_key": need_key,
-                "decision_context": decision_context,
-                "unknown_information": unknown_information,
+                "decision_context": judgement,
+                "unknown_information": judgement,
                 "source_ref": source_ref,
                 "priority": priority,
-                "metadata": {"route": route, "route_label": ROUTES[route]["label"]},
+                "metadata": {
+                    "route": route,
+                    "route_label": ROUTES[route]["label"],
+                    "source_field": field,
+                },
             }
         )
         raw_queries = raw.get("queries") or []
@@ -198,9 +329,9 @@ def _planning_payload(
                 {
                     "query_text": query_text,
                     "axes": {
-                        "需求路径": ROUTES[route]["label"],
-                        "创作决定": decision_context,
-                        "未知信息": unknown_information,
+                        "选题表字段": field["field_path"],
+                        "字段值": field["field_value"],
+                        "大模型判断": judgement,
                     },
                     "priority": priority,
                     "original_position": position,
@@ -215,17 +346,8 @@ def _planning_payload(
                 }
             )
             position += 1
-    invalid_routes = [
-        ROUTES[route]["label"]
-        for route, count in route_counts.items()
-        if count != 1
-    ]
-    if invalid_routes:
-        raise TopicTableGenerationError(
-            "大模型返回的六类问题不完整或有重复:" + "、".join(invalid_routes)
-        )
     if not needs or not candidates:
-        raise TopicTableGenerationError("大模型返回内容无法形成搜索问题和搜索词")
+        raise TopicTableGenerationError("大模型返回内容无法形成字段与搜索词的对应关系")
     return {
         "input_snapshot": snapshot,
         "knowledge_needs": needs,
@@ -233,7 +355,7 @@ def _planning_payload(
         "generator_config": {
             "prompt_name": PROMPT_NAME,
             "prompt_version": PROMPT_VERSION,
-            "routes": list(ROUTES),
+            "field_count": len(field_catalog),
         },
     }
 
@@ -247,16 +369,22 @@ def generate_topic_table_preview(
 ) -> TopicTablePreview:
     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)
+    payload = _planning_payload(
+        llm_output,
+        snapshot=snapshot,
+        field_catalog=field_catalog,
+    )
     request = GenerationRequest(
         generator_kind=GeneratorKind.TOPIC_TABLE,
         payload=payload,
@@ -304,6 +432,12 @@ def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
         point_result = str(point.get("point_result") or "").strip()
         if point_type in point_groups and point_result:
             point_groups[point_type].append(point_result)
+    queries_by_need: dict[str, list[str]] = {}
+    for query in result.selected_queries:
+        need_keys = query.knowledge_need_keys or [str(query.metadata.get("need_key") or "")]
+        for need_key in need_keys:
+            if need_key:
+                queries_by_need.setdefault(need_key, []).append(query.query_text)
     return {
         "generator_kind": GeneratorKind.TOPIC_TABLE.value,
         "source": preview.source,
@@ -330,6 +464,18 @@ def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
             }
             for need in result.knowledge_needs
         ],
+        "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 queries_by_need.get(need.need_key)
+        ],
         "queries": [
             {
                 "query": query.query_text,

+ 49 - 19
tests/test_topic_table_query_generation.py

@@ -107,23 +107,27 @@ def _topic_payload() -> dict[str, Any]:
 
 
 def _llm_output() -> dict[str, Any]:
-    needs = []
-    for index, (route, config) in enumerate(ROUTES.items()):
-        needs.append(
+    fields = [
+        ("point:8371:point_result", "灵感点怎么拍出来"),
+        ("item:25684:element_name", "低角度仰拍怎么执行"),
+        ("item:25688:element_name", "小羊怎么自然入镜"),
+        ("item:25700:element_name", "治愈感怎么通过画面表现"),
+        ("topic_direction.persona_dimensions.形式偏好", "怎么保持账号原来的拍法"),
+        ("item:25684:source:0:reason", "旧案例换场景后怎么调整"),
+    ]
+    return {
+        "field_mappings": [
             {
-                "need_key": f"need_{route}",
-                "route": route,
-                "decision_context": f"决定{config['label']}怎么做",
-                "unknown_information": f"缺少{config['label']}的可执行方法",
-                "priority": config["priority"],
-                "source_ref": {"point_id": 8371, "item_ids": [25684, 25688]},
+                "field_key": field_key,
+                "judgement": judgement,
                 "queries": [
-                    f"{config['label']}有哪些可执行方法",
-                    "逆光人像怎么拍出彩虹光晕" if index == 0 else f"{config['label']}常见失败原因",
+                    f"{judgement}有哪些方法",
+                    "逆光人像怎么拍出彩虹光晕" if index == 0 else f"{judgement}常见失败原因",
                 ],
             }
-        )
-    return {"knowledge_needs": needs}
+            for index, (field_key, judgement) in enumerate(fields)
+        ]
+    }
 
 
 def test_topic_source_calls_read_only_detail_endpoint():
@@ -150,7 +154,7 @@ def test_topic_source_calls_read_only_detail_endpoint():
     assert payload["source_url"].endswith("/1229")
 
 
-def test_topic_table_generation_uses_six_routes_and_unified_budget():
+def test_topic_table_generation_maps_real_fields_and_uses_unified_budget():
     seen = {}
 
     def chat(system, user):
@@ -178,7 +182,8 @@ def test_topic_table_generation_uses_six_routes_and_unified_budget():
         "意图": ["治愈感"],
     }
     assert "source_reference_data" not in seen["user"]
-    assert "正好有 6 项" in seen["user"]
+    assert "field_catalog" in seen["user"]
+    assert "topics[id=1392].composition_items[id=25688].element_name" in seen["user"]
     assert "废弃" not in seen["user"]
     assert "大模型要判断什么" in seen["system"]
     assert data["topic"]["input_groups"]["points"]["灵感点"] == [
@@ -187,14 +192,39 @@ def test_topic_table_generation_uses_six_routes_and_unified_budget():
     assert data["topic"]["input_groups"]["reference_notes"] == [
         {"element": "低角度仰拍", "note": "草坪案例证明低角度有动感"}
     ]
-    assert data["queries"][0]["route"] == "unique_hook"
+    assert data["field_mappings"][0] == {
+        "field_key": "point:8371:point_result",
+        "field_path": "topics[id=1392].points[id=8371].point_result",
+        "field_label": "灵感点字段",
+        "field_value": "低角度仰拍小羊和彩虹光晕",
+        "judgement": "灵感点怎么拍出来",
+        "queries": [
+            "灵感点怎么拍出来有哪些方法",
+            "逆光人像怎么拍出彩虹光晕",
+        ],
+    }
 
 
-def test_topic_table_generation_rejects_missing_or_duplicate_routes():
+def test_topic_table_generation_rejects_unknown_field_key():
     malformed = _llm_output()
-    malformed["knowledge_needs"][-1]["route"] = "unique_hook"
+    malformed["field_mappings"][-1]["field_key"] = "invented-field"
+
+    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_rejects_duplicate_field_key():
+    malformed = _llm_output()
+    malformed["field_mappings"][-1]["field_key"] = malformed["field_mappings"][0][
+        "field_key"
+    ]
 
-    with pytest.raises(TopicTableGenerationError, match="不完整或有重复"):
+    with pytest.raises(TopicTableGenerationError, match="重复引用"):
         generate_topic_table_preview(
             _topic_payload(),
             topic_id=1392,

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä