Kaynağa Gözat

Add topic-table query generation preview

SamLee 5 gün önce
ebeveyn
işleme
896d956956

+ 7 - 0
.env.example

@@ -24,6 +24,13 @@ CK_DB_ADMIN_PG_SYSTEM_USER=postgres
 CK_DB_ADMIN_PSQL="runuser -u postgres -- psql -d creation_knowledge_prod -v ON_ERROR_STOP=1"
 CK_DB_MIGRATION_APPLY="ssh -i <key> -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new root@<host> \"runuser -u postgres -- psql -d creation_knowledge_prod -v ON_ERROR_STOP=1\" < path/to/migration.sql"
 
+# -----------------------------------------------------------------------------
+# Upstream Pattern topic-build API (read only)
+# Used by the topic-table Query preview; it never starts search or writes a Batch.
+# -----------------------------------------------------------------------------
+PATTERN_API_BASE_URL=https://pattern.aiddit.com
+PATTERN_API_TIMEOUT_SECONDS=30
+
 # -----------------------------------------------------------------------------
 # Pipeline tracing ledger
 # -----------------------------------------------------------------------------

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 8
app/frontend/dist/assets/index-Cgnj5h6U.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 8 - 0
app/frontend/dist/assets/index-DjAGV0Zu.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
app/frontend/dist/assets/index-LXPDLFMK.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-Cgnj5h6U.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-DaHdrXy9.css">
+    <script type="module" crossorigin src="/app/assets/index-DjAGV0Zu.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-LXPDLFMK.css">
   </head>
   <body>
     <div id="root"></div>

+ 12 - 0
app/frontend/src/api/workbench.js

@@ -19,3 +19,15 @@ export function submitManualQueryBatch(payload, { signal } = {}) {
     signal,
   })
 }
+
+export function getTopicTableQueryPrompt({ signal } = {}) {
+  return request('/api/query-generation/topic-table/prompt', { signal })
+}
+
+export function generateTopicTableQueries(payload, { signal } = {}) {
+  return request('/api/query-generation/topic-table/preview', {
+    method: 'POST',
+    json: payload,
+    signal,
+  })
+}

+ 26 - 10
app/frontend/src/features/query-board/QueryBoardPage.jsx

@@ -6,6 +6,7 @@ import AxisColumn from './AxisColumn.jsx'
 import ManualQueryModal from './ManualQueryModal.jsx'
 import QueryColumn from './QueryColumn.jsx'
 import SearchResultColumn from './SearchResultColumn.jsx'
+import TopicTableQueryPanel from './TopicTableQueryPanel.jsx'
 import { useLatestQueryDetail, useQueryBoard } from './useQueryBoard.js'
 import { isQuerySearched } from './model.js'
 
@@ -39,6 +40,15 @@ function manualFamilyFromBoard(board) {
   }
 }
 
+function topicTableFamily() {
+  return {
+    key: 'topic_table',
+    label: '选题表生成',
+    axes: [],
+    items: [],
+  }
+}
+
 export default function QueryBoardPage() {
   const {
     preview,
@@ -58,7 +68,7 @@ export default function QueryBoardPage() {
   const families = useMemo(() => {
     const base = preview?.families || []
     const manual = manualFamilyFromBoard(board)
-    return [...base, manual]
+    return [...base, manual, topicTableFamily()]
   }, [board, preview])
   const family = families.find((row) => row.key === activeKey) || families[0] || { axes: [], items: [] }
   const familyItems = family.items || []
@@ -127,16 +137,22 @@ export default function QueryBoardPage() {
             </button>
           ))}
         </div>
-        <span className="refresh-state">
-          {lastUpdatedText ? `上次刷新 ${lastUpdatedText}` : '等待后台写入数据'}
-        </span>
-        <div className="manual-entry-actions">
-          <button type="button" onClick={() => setManualMode('text')}>手动输入</button>
-          <button type="button" onClick={() => setManualMode('json')}>上传 JSON</button>
-        </div>
+        {family.key === 'topic_table' ? (
+          <span className="refresh-state">选题字段 + LLM · 只生成 Query</span>
+        ) : (
+          <>
+            <span className="refresh-state">
+              {lastUpdatedText ? `上次刷新 ${lastUpdatedText}` : '等待后台写入数据'}
+            </span>
+            <div className="manual-entry-actions">
+              <button type="button" onClick={() => setManualMode('text')}>手动输入</button>
+              <button type="button" onClick={() => setManualMode('json')}>上传 JSON</button>
+            </div>
+          </>
+        )}
       </div>
 
-      <div className={`cdemo ${family.key === 'manual' ? 'manual-cdemo' : ''}`}>
+      {family.key === 'topic_table' ? <TopicTableQueryPanel /> : <div className={`cdemo ${family.key === 'manual' ? 'manual-cdemo' : ''}`}>
         {(family.axes || []).map((axis) => (
           <AxisColumn
             key={axis}
@@ -168,7 +184,7 @@ export default function QueryBoardPage() {
           error={detailState.error}
           onKnowledgeClick={setDecodeItemId}
         />
-      </div>
+      </div>}
       {decodeItemId && (
         <div className="decode-modal-mask" onClick={() => setDecodeItemId('')}>
           <div className="decode-modal-panel" onClick={(event) => event.stopPropagation()}>

+ 266 - 0
app/frontend/src/features/query-board/TopicTableQueryPanel.jsx

@@ -0,0 +1,266 @@
+import { useMemo, useState } from 'react'
+import { generateTopicTableQueries, getTopicTableQueryPrompt } from '../../api/workbench.js'
+
+const EXAMPLE = {
+  topicBuildId: '1229',
+  topicId: '1392',
+  maxQueries: '18',
+}
+
+const ROUTE_ORDER = [
+  'unique_hook',
+  'goal_execution',
+  'key_execution',
+  'cross_dimension',
+  'account_fit',
+  'evidence_gap',
+]
+
+function PromptModal({ payload, loading, error, onClose }) {
+  return (
+    <div className="manual-modal-mask" onClick={onClose}>
+      <section className="manual-modal-panel topic-prompt-modal" onClick={(event) => event.stopPropagation()}>
+        <header className="manual-modal-head">
+          <div>
+            <span className="topic-eyebrow">选题表 → Knowledge Need → Query</span>
+            <h2>生成 Prompt</h2>
+            <p>{payload ? `${payload.name} · ${payload.version}` : '读取当前线上使用的完整 Prompt'}</p>
+          </div>
+          <button className="manual-close" type="button" onClick={onClose} aria-label="关闭">×</button>
+        </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>
+          </>
+        )}
+      </section>
+    </div>
+  )
+}
+
+function SourceCard({ result }) {
+  const topic = result.topic || {}
+  const direction = topic.topic_direction || {}
+  const dimensions = topic.dimensions || {}
+  return (
+    <section className="topic-flow-column topic-source-column">
+      <div className="topic-flow-head">
+        <span className="topic-step">01</span>
+        <div>
+          <strong>选题表提供的信息</strong>
+          <small>真实选题与账号约束</small>
+        </div>
+      </div>
+      <div className="topic-source-card">
+        <div className="topic-source-meta">
+          <span>Build {result.source?.topic_build_id}</span>
+          <span>Topic {result.source?.topic_id}</span>
+          <span>{topic.status || 'unknown'}</span>
+        </div>
+        <h3>{direction.account_name || '选题结果'}</h3>
+        <p>{topic.result}</p>
+      </div>
+      {['实质', '形式', '意图'].map((dimension) => (
+        <div className="topic-dimension-group" key={dimension}>
+          <strong>{dimension}</strong>
+          <div className="topic-chip-list">
+            {(dimensions[dimension] || []).map((name) => <span key={`${dimension}-${name}`}>{name}</span>)}
+          </div>
+        </div>
+      ))}
+      <div className="topic-point-list">
+        <strong>选题点</strong>
+        {(topic.points || []).filter((point) => point.is_active !== false).map((point) => (
+          <div key={point.id}>
+            <span>{point.point_type}</span>
+            <p>{point.point_result}</p>
+          </div>
+        ))}
+      </div>
+    </section>
+  )
+}
+
+function NeedsColumn({ result }) {
+  const grouped = useMemo(() => {
+    const rows = new Map()
+    for (const need of result.knowledge_needs || []) {
+      rows.set(need.route, [...(rows.get(need.route) || []), need])
+    }
+    return rows
+  }, [result])
+  return (
+    <section className="topic-flow-column topic-needs-column">
+      <div className="topic-flow-head">
+        <span className="topic-step">02</span>
+        <div>
+          <strong>LLM 判断真正未知</strong>
+          <small>六条路径形成 Knowledge Need</small>
+        </div>
+      </div>
+      <div className="topic-needs-list">
+        {ROUTE_ORDER.flatMap((route) => grouped.get(route) || []).map((need) => (
+          <article className={`topic-need-card route-${need.route}`} key={need.need_key}>
+            <div className="topic-need-title">
+              <span>{need.route_label}</span>
+              <b>P{need.priority}</b>
+            </div>
+            <p><strong>要决定:</strong>{need.decision_context}</p>
+            <p><strong>真正未知:</strong>{need.unknown_information}</p>
+          </article>
+        ))}
+      </div>
+    </section>
+  )
+}
+
+function QueryPreviewColumn({ result }) {
+  return (
+    <section className="topic-flow-column topic-query-column">
+      <div className="topic-flow-head">
+        <span className="topic-step">03</span>
+        <div>
+          <strong>原子 Query</strong>
+          <small>标准化、精确去重、预算后的结果</small>
+        </div>
+      </div>
+      <div className="topic-query-summary">
+        <span><b>{result.summary?.selected_count || 0}</b> 条 Query</span>
+        <span>{result.summary?.unique_count || 0} 条去重后</span>
+        <em>未搜索</em>
+      </div>
+      <div className="topic-query-list">
+        {(result.queries || []).map((query, index) => (
+          <article key={`${query.query}-${index}`}>
+            <span className={`topic-query-route route-${query.route}`}>{query.route_label}</span>
+            <p>{query.query}</p>
+            <small>{query.axes?.未知信息}</small>
+          </article>
+        ))}
+      </div>
+    </section>
+  )
+}
+
+export default function TopicTableQueryPanel() {
+  const [topicBuildId, setTopicBuildId] = useState(EXAMPLE.topicBuildId)
+  const [topicId, setTopicId] = useState(EXAMPLE.topicId)
+  const [maxQueries, setMaxQueries] = useState(EXAMPLE.maxQueries)
+  const [result, setResult] = useState(null)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+  const [promptOpen, setPromptOpen] = useState(false)
+  const [prompt, setPrompt] = useState(null)
+  const [promptLoading, setPromptLoading] = useState(false)
+  const [promptError, setPromptError] = useState('')
+
+  const showPrompt = async () => {
+    setPromptOpen(true)
+    if (prompt || promptLoading) return
+    setPromptLoading(true)
+    setPromptError('')
+    try {
+      setPrompt(await getTopicTableQueryPrompt())
+    } catch (e) {
+      setPromptError(e.message || 'Prompt 读取失败')
+    } finally {
+      setPromptLoading(false)
+    }
+  }
+
+  const generate = async (event) => {
+    event.preventDefault()
+    setLoading(true)
+    setError('')
+    try {
+      const payload = {
+        topic_build_id: Number(topicBuildId),
+        topic_id: topicId ? Number(topicId) : null,
+        max_queries: Number(maxQueries),
+      }
+      setResult(await generateTopicTableQueries(payload))
+    } catch (e) {
+      setError(e.message || 'Query 生成失败')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="topic-table-panel">
+      <header className="topic-table-intro">
+        <div>
+          <span className="topic-eyebrow">第四种生成方式</span>
+          <h2>从选题表找出真正缺少的创作知识</h2>
+          <p>读取选题字段,交给 LLM 沿六条路径判断 Knowledge Need,再拆成原子 Query。</p>
+        </div>
+        <span className="topic-preview-only">仅生成 Query · 不创建 Batch · 不启动搜索</span>
+      </header>
+
+      <form className="topic-table-form" onSubmit={generate}>
+        <label>
+          <span>选题构建 ID</span>
+          <input value={topicBuildId} onChange={(e) => setTopicBuildId(e.target.value)} type="number" min="1" required />
+        </label>
+        <label>
+          <span>选题 ID</span>
+          <input value={topicId} onChange={(e) => setTopicId(e.target.value)} type="number" min="1" placeholder="不填则取 mature 选题" />
+        </label>
+        <label>
+          <span>Query 预算</span>
+          <input value={maxQueries} onChange={(e) => setMaxQueries(e.target.value)} type="number" min="6" max="60" required />
+        </label>
+        <button className="topic-example-button" type="button" onClick={() => {
+          setTopicBuildId(EXAMPLE.topicBuildId)
+          setTopicId(EXAMPLE.topicId)
+          setMaxQueries(EXAMPLE.maxQueries)
+          setResult(null)
+          setError('')
+        }}>载入真实示例 1229</button>
+        <button className="topic-prompt-button" type="button" onClick={showPrompt}>查看生成 Prompt</button>
+        <button className="topic-generate-button" type="submit" disabled={loading}>
+          {loading ? 'LLM 正在判断需求…' : '生成 Query 预览'}
+        </button>
+      </form>
+
+      {error && <div className="manual-error topic-generation-error">{error}</div>}
+      {!result && !loading && (
+        <section className="topic-example-card">
+          <div>
+            <span>真实示例</span>
+            <strong>1229 / 1392 · 樱花树下少女与小羊互动穿搭摄影</strong>
+            <p>系统会从低角度、彩虹光晕、治愈感、好物分享等真实字段判断还缺哪些创作知识。</p>
+          </div>
+          <ul>
+            <li>逆光人像怎么拍出彩虹光晕</li>
+            <li>人物和小羊互动怎么拍得自然</li>
+            <li>氛围感穿搭图文如何自然展示配饰</li>
+          </ul>
+        </section>
+      )}
+      {loading && <div className="topic-loading-card"><span />正在读取选题表并生成六路 Knowledge Need…</div>}
+      {result && (
+        <div className="topic-flow-grid">
+          <SourceCard result={result} />
+          <NeedsColumn result={result} />
+          <QueryPreviewColumn result={result} />
+        </div>
+      )}
+      {promptOpen && (
+        <PromptModal
+          payload={prompt}
+          loading={promptLoading}
+          error={promptError}
+          onClose={() => setPromptOpen(false)}
+        />
+      )}
+    </div>
+  )
+}

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

@@ -236,3 +236,516 @@
   font-size: 13px;
   line-height: 1.6;
 }
+
+/* Topic-table Query preview: source → six needs → atomic queries. */
+.topic-table-panel {
+  min-height: calc(100vh - 78px);
+  padding: 18px;
+  border: 1px solid #d9d3f3;
+  border-radius: 10px;
+  background: #f8f7fc;
+  color: #1d2129;
+}
+
+.topic-table-intro {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24px;
+  padding: 2px 2px 16px;
+}
+
+.topic-eyebrow {
+  display: block;
+  margin-bottom: 5px;
+  color: #6e56cf;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: .08em;
+  text-transform: uppercase;
+}
+
+.topic-table-intro h2 {
+  margin: 0 0 7px;
+  color: #202039;
+  font-size: 22px;
+  line-height: 1.25;
+}
+
+.topic-table-intro p {
+  margin: 0;
+  color: #646477;
+  font-size: 13px;
+}
+
+.topic-preview-only {
+  flex: none;
+  padding: 7px 10px;
+  border: 1px solid #b9ead6;
+  border-radius: 999px;
+  background: #edfff7;
+  color: #16885e;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.topic-table-form {
+  display: grid;
+  grid-template-columns: 150px 150px 120px auto auto minmax(160px, auto);
+  align-items: end;
+  gap: 10px;
+  padding: 14px;
+  border: 1px solid #e4e0f2;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 4px 16px rgba(61, 49, 112, .05);
+}
+
+.topic-table-form label {
+  display: grid;
+  gap: 6px;
+  color: #5d5d70;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.topic-table-form input {
+  width: 100%;
+  height: 34px;
+  box-sizing: border-box;
+  padding: 0 10px;
+  border: 1px solid #d8d4e8;
+  border-radius: 6px;
+  background: #fff;
+  color: #202039;
+  font-size: 13px;
+  outline: none;
+}
+
+.topic-table-form input:focus {
+  border-color: #7c66d8;
+  box-shadow: 0 0 0 3px rgba(110, 86, 207, .12);
+}
+
+.topic-table-form button {
+  height: 34px;
+  padding: 0 12px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 750;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.topic-example-button,
+.topic-prompt-button {
+  border: 1px solid #d8d4e8;
+  background: #fff;
+  color: #5d4e9d;
+}
+
+.topic-example-button:hover,
+.topic-prompt-button:hover {
+  border-color: #a99be0;
+  background: #f5f2ff;
+}
+
+.topic-generate-button {
+  border: 1px solid #6e56cf;
+  background: #6e56cf;
+  color: #fff;
+  box-shadow: 0 5px 12px rgba(110, 86, 207, .18);
+}
+
+.topic-generate-button:hover:not(:disabled) {
+  background: #5c45be;
+}
+
+.topic-generate-button:disabled {
+  border-color: #bab1df;
+  background: #bab1df;
+  cursor: wait;
+  box-shadow: none;
+}
+
+.topic-example-card {
+  display: grid;
+  grid-template-columns: minmax(360px, 1.3fr) minmax(320px, 1fr);
+  gap: 28px;
+  margin-top: 16px;
+  padding: 20px 22px;
+  border: 1px dashed #bbb1df;
+  border-radius: 9px;
+  background: linear-gradient(120deg, #fff 0%, #f5f2ff 100%);
+}
+
+.topic-example-card span {
+  display: block;
+  margin-bottom: 6px;
+  color: #6e56cf;
+  font-size: 11px;
+  font-weight: 800;
+}
+
+.topic-example-card strong {
+  color: #292444;
+  font-size: 15px;
+}
+
+.topic-example-card p,
+.topic-example-card li {
+  color: #68657a;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.topic-example-card ul {
+  margin: 0;
+  padding: 3px 0 0 20px;
+}
+
+.topic-loading-card {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  min-height: 180px;
+  margin-top: 16px;
+  border: 1px solid #e4e0f2;
+  border-radius: 8px;
+  background: #fff;
+  color: #625a82;
+  font-size: 13px;
+}
+
+.topic-loading-card span {
+  width: 14px;
+  height: 14px;
+  border: 2px solid #d9d2f2;
+  border-top-color: #6e56cf;
+  border-radius: 50%;
+  animation: topic-spin .8s linear infinite;
+}
+
+@keyframes topic-spin {
+  to { transform: rotate(360deg); }
+}
+
+.topic-generation-error {
+  margin-top: 12px;
+}
+
+.topic-flow-grid {
+  display: grid;
+  grid-template-columns: minmax(280px, .9fr) minmax(340px, 1.05fr) minmax(360px, 1.15fr);
+  gap: 12px;
+  margin-top: 16px;
+}
+
+.topic-flow-column {
+  position: relative;
+  min-width: 0;
+  max-height: calc(100vh - 250px);
+  padding: 14px;
+  overflow: auto;
+  border: 1px solid #e1ddeb;
+  border-radius: 8px;
+  background: #fff;
+}
+
+.topic-flow-column:not(:last-child)::after {
+  position: fixed;
+  content: '';
+}
+
+.topic-flow-head {
+  position: sticky;
+  top: -14px;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  margin: -14px -14px 12px;
+  padding: 12px 14px;
+  border-bottom: 1px solid #ece9f3;
+  background: rgba(255, 255, 255, .96);
+  backdrop-filter: blur(8px);
+}
+
+.topic-step {
+  display: grid;
+  width: 29px;
+  height: 29px;
+  flex: none;
+  place-items: center;
+  border-radius: 7px;
+  background: #eeeaff;
+  color: #6048be;
+  font: 800 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.topic-flow-head strong {
+  display: block;
+  color: #292444;
+  font-size: 13px;
+}
+
+.topic-flow-head small {
+  color: #9690a8;
+  font-size: 10px;
+}
+
+.topic-source-card {
+  padding: 12px;
+  border: 1px solid #e4e0f2;
+  border-radius: 7px;
+  background: #faf9ff;
+}
+
+.topic-source-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+}
+
+.topic-source-meta span {
+  padding: 2px 6px;
+  border-radius: 4px;
+  background: #ece8fb;
+  color: #62529d;
+  font-size: 9px;
+  font-weight: 700;
+}
+
+.topic-source-card h3 {
+  margin: 10px 0 7px;
+  color: #292444;
+  font-size: 14px;
+}
+
+.topic-source-card p {
+  margin: 0;
+  color: #5f5c6d;
+  font-size: 11px;
+  line-height: 1.65;
+}
+
+.topic-dimension-group {
+  margin-top: 12px;
+}
+
+.topic-dimension-group > strong,
+.topic-point-list > strong {
+  color: #514c63;
+  font-size: 11px;
+}
+
+.topic-chip-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+  margin-top: 6px;
+}
+
+.topic-chip-list span {
+  padding: 3px 7px;
+  border: 1px solid #e4e0ef;
+  border-radius: 999px;
+  background: #fff;
+  color: #666174;
+  font-size: 10px;
+}
+
+.topic-point-list {
+  margin-top: 14px;
+  padding-top: 12px;
+  border-top: 1px solid #eeeaf3;
+}
+
+.topic-point-list > div {
+  margin-top: 8px;
+  padding-left: 9px;
+  border-left: 2px solid #c7bbea;
+}
+
+.topic-point-list span {
+  color: #765fc4;
+  font-size: 9px;
+  font-weight: 800;
+}
+
+.topic-point-list p {
+  margin: 2px 0 0;
+  color: #595569;
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.topic-needs-list,
+.topic-query-list {
+  display: grid;
+  gap: 8px;
+}
+
+.topic-need-card {
+  padding: 10px 11px;
+  border: 1px solid #e3dfeb;
+  border-left-width: 3px;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.topic-need-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+
+.topic-need-title span {
+  color: #514b62;
+  font-size: 10px;
+  font-weight: 800;
+}
+
+.topic-need-title b {
+  color: #9a94a7;
+  font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.topic-need-card p {
+  margin: 4px 0;
+  color: #696575;
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.topic-need-card p strong {
+  color: #3f3a4e;
+}
+
+.route-unique_hook { border-left-color: #7c5ce5; }
+.route-goal_execution { border-left-color: #2c9d73; }
+.route-key_execution { border-left-color: #3185d6; }
+.route-cross_dimension { border-left-color: #df8b2e; }
+.route-account_fit { border-left-color: #cc5a8b; }
+.route-evidence_gap { border-left-color: #df5d55; }
+
+.topic-query-summary {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 10px;
+  padding: 8px 9px;
+  border-radius: 6px;
+  background: #f7f5fc;
+  color: #777184;
+  font-size: 10px;
+}
+
+.topic-query-summary b {
+  color: #5e47b8;
+  font-size: 15px;
+}
+
+.topic-query-summary em {
+  margin-left: auto;
+  color: #a36b17;
+  font-style: normal;
+  font-weight: 800;
+}
+
+.topic-query-list article {
+  padding: 9px 10px;
+  border: 1px solid #e4e1eb;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.topic-query-route {
+  display: inline-block;
+  padding-left: 7px;
+  border-left: 3px solid #7c5ce5;
+  color: #777184;
+  font-size: 9px;
+  font-weight: 800;
+}
+
+.topic-query-list p {
+  margin: 5px 0 3px;
+  color: #292534;
+  font-size: 12px;
+  font-weight: 650;
+  line-height: 1.45;
+}
+
+.topic-query-list small {
+  display: block;
+  color: #9993a3;
+  font-size: 9px;
+  line-height: 1.4;
+}
+
+.topic-prompt-modal {
+  width: min(920px, 100%);
+}
+
+.topic-route-strip {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.topic-route-strip span {
+  padding: 4px 8px;
+  border: 1px solid #ded8f1;
+  border-radius: 999px;
+  background: #f6f3ff;
+  color: #6654aa;
+  font-size: 10px;
+  font-weight: 750;
+}
+
+.topic-prompt-code {
+  max-height: calc(100vh - 250px);
+  margin: 0;
+  padding: 14px 16px;
+  overflow: auto;
+  border: 1px solid #e3dfeb;
+  border-radius: 7px;
+  background: #252334;
+  color: #ebe8f7;
+  font: 11px/1.65 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  white-space: pre-wrap;
+}
+
+.topic-inline-state {
+  color: #777184;
+  font-size: 12px;
+}
+
+@media (max-width: 1180px) {
+  .topic-table-form {
+    grid-template-columns: repeat(3, minmax(120px, 1fr));
+  }
+
+  .topic-flow-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .topic-flow-column {
+    max-height: none;
+  }
+}
+
+@media (max-width: 720px) {
+  .topic-table-panel { padding: 12px; }
+  .topic-table-intro { flex-direction: column; }
+  .topic-table-form { grid-template-columns: 1fr; }
+  .topic-example-card { grid-template-columns: 1fr; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .topic-loading-card span { animation: none; }
+}

+ 80 - 2
app/routes/query_generation.py

@@ -4,13 +4,28 @@ from __future__ import annotations
 from typing import Any
 from uuid import UUID
 
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
 
 from acquisition.queries.builder import QueryBuildOptions, TREES, build_creation_query_batch
 from app.dependencies import _env_file, get_acquisition_repository
 from app.routes.acquisition import _model_dump
-from app.schemas import QuerySchema
+from app.schemas import QuerySchema, TopicTableQueryPreviewRequest
 from core.config import Settings
+from core.llm import LLMError, chat_json
+from query_planning.sources import (
+    PatternTopicBuildConfig,
+    PatternTopicBuildSource,
+    TopicBuildSourceError,
+)
+from query_planning.topic_table import (
+    PROMPT_NAME,
+    PROMPT_VERSION,
+    ROUTES,
+    TopicTableGenerationError,
+    generate_topic_table_preview,
+    preview_to_dict,
+    topic_table_prompt,
+)
 
 router = APIRouter(prefix="/api/query-generation", tags=["query-generation"])
 
@@ -135,6 +150,69 @@ def query_generation_preview(
     return generated
 
 
+def _topic_source(env_file: str) -> PatternTopicBuildSource:
+    return PatternTopicBuildSource(PatternTopicBuildConfig.from_env(env_file))
+
+
+def _topic_query_chat(system: str, user: str, *, settings: Settings) -> dict[str, Any]:
+    return chat_json(
+        system,
+        user,
+        settings=settings,
+        timeout=settings.bailian_timeout_seconds,
+        trace_stage="query_planning",
+        trace_substage="topic_table_query_generation",
+        prompt_name=PROMPT_NAME,
+    )
+
+
+@router.get("/topic-table/prompt")
+def topic_table_generation_prompt() -> dict[str, Any]:
+    """Return the exact versioned Prompt shown by the topic-table preview UI."""
+
+    return {
+        "name": PROMPT_NAME,
+        "version": PROMPT_VERSION,
+        "routes": [
+            {"key": key, "label": value["label"], "default_priority": value["priority"]}
+            for key, value in ROUTES.items()
+        ],
+        "system_prompt": topic_table_prompt(),
+        "example_request": {
+            "topic_build_id": 1229,
+            "topic_id": 1392,
+            "max_queries": 18,
+        },
+    }
+
+
+@router.post("/topic-table/preview")
+def topic_table_query_preview(request: TopicTableQueryPreviewRequest) -> dict[str, Any]:
+    """Generate Query preview from one topic table without persisting or searching."""
+
+    env_file = _env_file()
+    try:
+        source_payload = _topic_source(env_file).fetch(request.topic_build_id)
+        settings = Settings.from_env(env_file)
+        preview = generate_topic_table_preview(
+            source_payload,
+            topic_id=request.topic_id,
+            max_queries=request.max_queries,
+            chat_fn=lambda system, user: _topic_query_chat(
+                system,
+                user,
+                settings=settings,
+            ),
+        )
+        return preview_to_dict(preview)
+    except TopicBuildSourceError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
+    except LLMError as exc:
+        raise HTTPException(status_code=502, detail=f"Query 生成模型调用失败:{exc}") from exc
+    except TopicTableGenerationError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
 @router.get("/latest-singleton")
 def latest_singleton_overview(repo: Any = Depends(get_acquisition_repository)) -> dict[str, Any]:
     """Return links from the query preview board to the latest real singleton run."""

+ 6 - 0
app/schemas.py

@@ -47,6 +47,12 @@ class QuerySchema(ApiSchema):
     sort_order: int = 0
 
 
+class TopicTableQueryPreviewRequest(ApiSchema):
+    topic_build_id: int = Field(gt=0)
+    topic_id: int | None = Field(default=None, gt=0)
+    max_queries: int = Field(default=18, ge=6, le=60)
+
+
 class MediaAssetSchema(ApiSchema):
     id: UUID
     item_id: UUID

+ 76 - 0
prompts/topic_table_query_generation.txt

@@ -0,0 +1,76 @@
+你是“选题表创作知识需求规划器”。输入是一条已经定稿或接近定稿的选题,以及它的账号人设、选题点、实质/形式/意图元素、元素关系和已有来源。
+
+你的任务不是复述选题,也不是判断外部事实真假,而是识别:为了把这个选题真正创作出来,创作者还缺哪些可迁移、可复用的创作知识,并把每个知识缺口拆成原子搜索 Query。
+
+必须从下面六条需求路径逐项检查;数据足够时,每条路径至少输出一个 Knowledge Need:
+
+1. unique_hook(独特切口落地)
+   从灵感点、独特组合和高辨识度元素出发,判断这个切口真正执行时还缺什么方法。
+
+2. goal_execution(创作目标实现)
+   从目的点、意图和最终 result 出发,判断治愈、种草、记忆点等目标如何通过内容实现。
+
+3. key_execution(关键点执行)
+   从关键点和具体形式元素出发,判断镜头、动作、结构、光线、构图等如何实际执行。
+
+4. cross_dimension(跨维度组合)
+   组合实质、形式和意图,判断多个元素放在同一内容中时,如何组织、取舍并保持主线清楚。
+
+5. account_fit(账号适配)
+   从 topic_direction 和 persona_dimensions 出发,判断本次选题如何延续账号已有的视觉语言、表达方式和商业目标。
+
+6. evidence_gap(来源与推导缺口)
+   检查 sources、item_relations 和 reason。已有案例可能只证明某个元素有效,但没有说明迁移到当前场景后怎么做;只围绕创作方法迁移、执行失败点和画面控制形成需求。
+
+判断规则:
+
+- Knowledge Need 只写“当前要做什么创作决定”和“真正未知的信息”,不要预先标成 What、How、Why;What/How/Why 是搜索内容解构后的知识类型。
+- 已经在选题表里明确决定的对象、场景和目标,不要当成未知信息重复搜索。
+- 一个 Query 只解决一个创作判断,不要把场景、穿搭、拍摄、情绪、结构全部塞进一句。
+- Query 应使用真实创作者会搜索的自然语言,避免空泛词和数据库字段名。
+- 优先搜索可迁移的创作方法、组织方式、决策依据和失败规避;不要只搜索这个具体选题标题。
+- 只生成“创作知识搜索”,不要生成政策法规、平台审核、保险报备、科学事实、行业数据、效果数值等事实取证 Query。
+- 不要在 Query 中写具体账号名、年份、topic_id、item_id,也不要假设存在精确的色彩阈值或情绪数值。
+- account_fit 要把账号偏好转译成可迁移的视觉风格、内容结构或种草方法,不要搜索具体账号自身。
+- evidence_gap 要搜索旧案例迁移到新场景时怎么调整、哪里容易失败,不要搜索外部合规规则。
+- knowledge_needs 必须正好输出 6 项:六条路径各 1 项,不多不少。不要为了凑 Query 总数,把同一路径拆成多个 Knowledge Need。
+- 每个 Knowledge Need 默认产出 2~3 条互不重复的原子 Query;Query 数量放在 queries 内,不是增加 Knowledge Need 数量。
+- decision_context 只描述“要做的创作决定”;unknown_information 只概括“还缺哪类信息”。二者都不能提前编造机位厘米数、镜头参数、比例、阈值、转化率或所谓研究结论。
+- 相似 Query 可以保留,但完全重复的 Query 不要重复输出。
+- 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": [
+    {
+      "need_key": "稳定且简短的英文键",
+      "route": "六个固定路径之一",
+      "decision_context": "当前要做的创作决定",
+      "unknown_information": "为了做出这个决定,真正缺少的信息",
+      "priority": 0,
+      "source_ref": {
+        "point_id": null,
+        "item_ids": []
+      },
+      "queries": [
+        "只解决一个判断的原子 Query"
+      ]
+    }
+  ]
+}
+
+priority 建议范围为 0~100:独特切口和强制目标最高,关键执行和跨维度组合其次,账号适配与证据缺口根据实际风险排序。
+
+合格示例:
+- 逆光人像怎么拍出自然彩虹光晕
+- 人物和小羊互动怎么拍得自然
+- 氛围感穿搭图文如何自然展示配饰
+- 低角度人像如何避免人物比例变形
+
+不合格示例:
+- 小红书动物内容审核规则
+- 某某账号爆款照片数据分析
+- 2024年户外拍摄保险要求
+- 樱花小羊CCD治愈穿搭怎么拍(一次混入太多判断)

+ 2 - 0
query_planning/__init__.py

@@ -15,6 +15,7 @@ from query_planning.generators import (
     CartesianQueryGenerator,
     ManualQueryGenerator,
     QueryGeneratorRegistry,
+    TopicTableQueryGenerator,
     default_generator_registry,
 )
 from query_planning.ports import LegacyQueryBatchSink, QueryGenerator, QueryPlanningStore
@@ -45,6 +46,7 @@ __all__ = [
     "QueryGenerator",
     "QueryGeneratorRegistry",
     "QueryPlanningStore",
+    "TopicTableQueryGenerator",
     "UnifiedQueryGenerationService",
     "UnsupportedGeneratorError",
     "default_generator_registry",

+ 9 - 1
query_planning/generators.py

@@ -92,6 +92,12 @@ class ManualQueryGenerator(_PayloadQueryGenerator):
     kind = GeneratorKind.MANUAL
 
 
+class TopicTableQueryGenerator(_PayloadQueryGenerator):
+    """Consumes candidates already derived from a topic snapshot by the LLM planner."""
+
+    kind = GeneratorKind.TOPIC_TABLE
+
+
 class QueryGeneratorRegistry:
     def __init__(self, generators: Iterable[QueryGenerator] = ()) -> None:
         self._generators: dict[GeneratorKind, QueryGenerator] = {}
@@ -109,4 +115,6 @@ class QueryGeneratorRegistry:
 
 
 def default_generator_registry() -> QueryGeneratorRegistry:
-    return QueryGeneratorRegistry((CartesianQueryGenerator(), ManualQueryGenerator()))
+    return QueryGeneratorRegistry(
+        (CartesianQueryGenerator(), ManualQueryGenerator(), TopicTableQueryGenerator())
+    )

+ 13 - 0
query_planning/sources/__init__.py

@@ -0,0 +1,13 @@
+"""Infrastructure adapters used before Query Planning."""
+
+from query_planning.sources.pattern_topic_build import (
+    PatternTopicBuildConfig,
+    PatternTopicBuildSource,
+    TopicBuildSourceError,
+)
+
+__all__ = [
+    "PatternTopicBuildConfig",
+    "PatternTopicBuildSource",
+    "TopicBuildSourceError",
+]

+ 62 - 0
query_planning/sources/pattern_topic_build.py

@@ -0,0 +1,62 @@
+"""Read-only adapter for the Pattern topic-build detail API."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable
+
+import httpx
+
+from core.config import env_value, load_env_file
+
+
+class TopicBuildSourceError(RuntimeError):
+    """Raised when the upstream topic-build API cannot provide a valid snapshot."""
+
+
+@dataclass(frozen=True)
+class PatternTopicBuildConfig:
+    base_url: str = "https://pattern.aiddit.com"
+    timeout_seconds: float = 30.0
+
+    @classmethod
+    def from_env(cls, env_file: str | Path = ".env") -> "PatternTopicBuildConfig":
+        file_env = load_env_file(env_file)
+        return cls(
+            base_url=env_value(
+                "PATTERN_API_BASE_URL",
+                file_env,
+                "https://pattern.aiddit.com",
+            ).rstrip("/"),
+            timeout_seconds=float(
+                env_value("PATTERN_API_TIMEOUT_SECONDS", file_env, "30")
+            ),
+        )
+
+
+class PatternTopicBuildSource:
+    def __init__(
+        self,
+        config: PatternTopicBuildConfig,
+        *,
+        http_get: Callable[..., Any] = httpx.get,
+    ) -> None:
+        self.config = config
+        self.http_get = http_get
+
+    def fetch(self, topic_build_id: int) -> dict[str, Any]:
+        url = f"{self.config.base_url}/api/pattern/topic_builds/{topic_build_id}"
+        try:
+            response = self.http_get(url, timeout=self.config.timeout_seconds)
+            response.raise_for_status()
+            payload = response.json()
+        except (httpx.HTTPError, ValueError, TypeError) as exc:
+            raise TopicBuildSourceError(f"选题接口读取失败:{exc}") from exc
+        if not isinstance(payload, dict) or payload.get("success") is not True:
+            raise TopicBuildSourceError("选题接口没有返回 success=true")
+        topics = payload.get("topics")
+        if not isinstance(topics, list) or not topics:
+            raise TopicBuildSourceError("这次选题构建没有可用的 topics")
+        payload = dict(payload)
+        payload["source_url"] = url
+        return payload

+ 339 - 0
query_planning/topic_table.py

@@ -0,0 +1,339 @@
+"""Application service for deriving Query candidates from one topic-table snapshot."""
+from __future__ import annotations
+
+import json
+import re
+from copy import deepcopy
+from dataclasses import dataclass
+from typing import Any, Callable
+
+from core.prompts import load_prompt
+from query_planning.domain import GenerationRequest, GenerationResult, GeneratorKind
+from query_planning.service import UnifiedQueryGenerationService
+
+
+PROMPT_NAME = "topic_table_query_generation"
+PROMPT_VERSION = "topic_table_query_v1"
+
+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},
+}
+
+
+class TopicTableGenerationError(ValueError):
+    """Raised when a topic snapshot or LLM result cannot become a Query plan."""
+
+
+@dataclass(frozen=True)
+class TopicTablePreview:
+    result: GenerationResult
+    source: dict[str, Any]
+    topic: dict[str, Any]
+    llm_output: dict[str, Any]
+
+
+def topic_table_prompt() -> str:
+    return load_prompt(PROMPT_NAME)
+
+
+def _select_topic(payload: dict[str, Any], topic_id: int | None) -> dict[str, Any]:
+    topics = [topic for topic in payload.get("topics") or [] if isinstance(topic, dict)]
+    if topic_id is not None:
+        for topic in topics:
+            if int(topic.get("id") or 0) == topic_id:
+                return topic
+        raise TopicTableGenerationError(f"选题构建中找不到 topic_id={topic_id}")
+    mature = next((topic for topic in topics if topic.get("status") == "mature"), None)
+    if mature is not None:
+        return mature
+    if topics:
+        return topics[0]
+    raise TopicTableGenerationError("选题构建中没有可用 topic")
+
+
+def _source_summary(source: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "derivation_type": source.get("derivation_type"),
+        "source_type": source.get("source_type"),
+        "source_reference_id": source.get("source_reference_id"),
+        "reason": source.get("reason"),
+        "dataset_from": source.get("dataset_from"),
+    }
+
+
+def _compact_snapshot(
+    payload: dict[str, Any],
+    topic: dict[str, Any],
+) -> dict[str, Any]:
+    build = payload.get("build") or {}
+    items = []
+    for item in topic.get("composition_items") or []:
+        if not isinstance(item, dict) or item.get("is_active") is False:
+            continue
+        items.append(
+            {
+                "id": item.get("id"),
+                "item_level": item.get("item_level"),
+                "dimension": item.get("dimension"),
+                "point_type": item.get("point_type"),
+                "element_name": item.get("element_name"),
+                "category_path": item.get("category_path"),
+                "reason": item.get("reason"),
+                "is_active": item.get("is_active"),
+                "sources": [
+                    _source_summary(source)
+                    for source in item.get("sources") or []
+                    if isinstance(source, dict)
+                ],
+            }
+        )
+    return {
+        "topic_build_id": build.get("id"),
+        "topic_id": topic.get("id"),
+        "build": {
+            "demand": build.get("demand"),
+            "demand_constraints": build.get("demand_constraints"),
+            "strategies_config": build.get("strategies_config"),
+            "status": build.get("status"),
+        },
+        "topic": {
+            "status": topic.get("status"),
+            "result": topic.get("result"),
+            "topic_direction": topic.get("topic_direction"),
+            "points": [
+                deepcopy(point)
+                for point in topic.get("points") or []
+                if isinstance(point, dict) and point.get("is_active") is not False
+            ],
+            "composition_items": items,
+            "item_relations": deepcopy(topic.get("item_relations") or []),
+        },
+    }
+
+
+def build_topic_table_user_prompt(
+    snapshot: dict[str, Any],
+    *,
+    target_query_count: int,
+) -> str:
+    return (
+        "请根据下面的真实选题表快照,沿六条固定路径识别 Knowledge Need。"
+        "knowledge_needs 数组必须正好有 6 项,每条路径正好 1 项,不能把 Query 数量误当成 Need 数量。"
+        f"六个 Need 合计生成约 {target_query_count} 条原子 Query;预算允许时,每个 Need 给 2~3 条。"
+        "只输出待搜索的问题,不要在 decision_context 或 unknown_information 中提前编造答案、参数、比例或效果数据。"
+        "不要启动搜索。\n\n"
+        + json.dumps(snapshot, ensure_ascii=False, indent=2, default=str)
+    )
+
+
+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}"
+
+
+def _planning_payload(
+    llm_output: dict[str, Any],
+    *,
+    snapshot: 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("LLM 没有返回 knowledge_needs")
+    topic_build_id = snapshot["topic_build_id"]
+    topic_id = snapshot["topic_id"]
+    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}
+    position = 0
+    for index, raw in enumerate(raw_needs):
+        if not isinstance(raw, dict):
+            continue
+        route = str(raw.get("route") or "").strip()
+        if route not in ROUTES:
+            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 {}
+        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 []),
+        }
+        needs.append(
+            {
+                "need_key": need_key,
+                "decision_context": decision_context,
+                "unknown_information": unknown_information,
+                "source_ref": source_ref,
+                "priority": priority,
+                "metadata": {"route": route, "route_label": ROUTES[route]["label"]},
+            }
+        )
+        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(
+                {
+                    "query_text": query_text,
+                    "axes": {
+                        "需求路径": ROUTES[route]["label"],
+                        "创作决定": decision_context,
+                        "未知信息": unknown_information,
+                    },
+                    "priority": priority,
+                    "original_position": position,
+                    "source_refs": [source_ref],
+                    "knowledge_need_keys": [need_key],
+                    "metadata": {
+                        "family_key": GeneratorKind.TOPIC_TABLE.value,
+                        "route": route,
+                        "route_label": ROUTES[route]["label"],
+                        "need_key": need_key,
+                    },
+                }
+            )
+            position += 1
+    invalid_routes = [
+        ROUTES[route]["label"]
+        for route, count in route_counts.items()
+        if count != 1
+    ]
+    if invalid_routes:
+        raise TopicTableGenerationError(
+            "LLM 返回的六路 Knowledge Need 不完整或有重复:" + "、".join(invalid_routes)
+        )
+    if not needs or not candidates:
+        raise TopicTableGenerationError("LLM 返回内容无法形成 Knowledge Need 和 Query")
+    return {
+        "input_snapshot": snapshot,
+        "knowledge_needs": needs,
+        "candidates": candidates,
+        "generator_config": {
+            "prompt_name": PROMPT_NAME,
+            "prompt_version": PROMPT_VERSION,
+            "routes": list(ROUTES),
+        },
+    }
+
+
+def generate_topic_table_preview(
+    source_payload: dict[str, Any],
+    *,
+    topic_id: int | None,
+    max_queries: int | None,
+    chat_fn: Callable[[str, str], dict[str, Any]],
+) -> TopicTablePreview:
+    topic = _select_topic(source_payload, topic_id)
+    snapshot = _compact_snapshot(source_payload, topic)
+    llm_output = chat_fn(
+        topic_table_prompt(),
+        build_topic_table_user_prompt(
+            snapshot,
+            target_query_count=max_queries or 18,
+        ),
+    )
+    if not isinstance(llm_output, dict):
+        raise TopicTableGenerationError("LLM 返回值不是 JSON 对象")
+    payload = _planning_payload(llm_output, snapshot=snapshot)
+    request = GenerationRequest(
+        generator_kind=GeneratorKind.TOPIC_TABLE,
+        payload=payload,
+        name=f"topic-table-{snapshot['topic_build_id']}-{snapshot['topic_id']}",
+        max_queries=max_queries,
+        metadata={
+            "source": "pattern_topic_build_api",
+            "prompt_version": PROMPT_VERSION,
+        },
+    )
+    result = UnifiedQueryGenerationService().generate(request)
+    return TopicTablePreview(
+        result=result,
+        source={
+            "topic_build_id": snapshot["topic_build_id"],
+            "topic_id": snapshot["topic_id"],
+            "source_url": source_payload.get("source_url"),
+        },
+        topic=snapshot["topic"],
+        llm_output=llm_output,
+    )
+
+
+def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
+    result = preview.result
+    route_counts: dict[str, int] = {route: 0 for route in ROUTES}
+    for need in result.knowledge_needs:
+        route = str(need.metadata.get("route") or "")
+        if route in route_counts:
+            route_counts[route] += 1
+    dimensions: dict[str, list[str]] = {"实质": [], "形式": [], "意图": []}
+    for item in preview.topic.get("composition_items") or []:
+        dimension = item.get("dimension")
+        name = item.get("element_name")
+        if dimension in dimensions and name and item.get("is_active"):
+            dimensions[dimension].append(name)
+    return {
+        "generator_kind": GeneratorKind.TOPIC_TABLE.value,
+        "source": preview.source,
+        "topic": {
+            "status": preview.topic.get("status"),
+            "result": preview.topic.get("result"),
+            "topic_direction": preview.topic.get("topic_direction"),
+            "dimensions": dimensions,
+            "points": preview.topic.get("points") or [],
+        },
+        "knowledge_needs": [
+            {
+                "need_key": need.need_key,
+                "route": need.metadata.get("route"),
+                "route_label": need.metadata.get("route_label"),
+                "decision_context": need.decision_context,
+                "unknown_information": need.unknown_information,
+                "priority": need.priority,
+                "source_ref": need.source_ref,
+            }
+            for need in result.knowledge_needs
+        ],
+        "queries": [
+            {
+                "query": query.query_text,
+                "priority": query.priority,
+                "route": query.metadata.get("route"),
+                "route_label": query.metadata.get("route_label"),
+                "need_key": query.metadata.get("need_key"),
+                "axes": query.axes,
+                "source_refs": query.source_refs,
+            }
+            for query in result.selected_queries
+        ],
+        "summary": {
+            "generated_count": result.stats.generated_count,
+            "unique_count": result.stats.unique_count,
+            "selected_count": result.stats.selected_count,
+            "dropped_count": result.stats.dropped_count,
+            "route_counts": route_counts,
+            "persisted": False,
+            "search_started": False,
+        },
+        "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
+    }

+ 2 - 2
tests/test_query_planning_core.py

@@ -91,9 +91,9 @@ def test_empty_selection_and_unregistered_reserved_generator_fail_explicitly():
         service.generate(_request(candidates=["  "]))
     with pytest.raises(NoQueryCandidatesError):
         service.generate(_request(candidates=["valid"], max_queries=0))
-    with pytest.raises(UnsupportedGeneratorError, match="topic_table"):
+    with pytest.raises(UnsupportedGeneratorError, match="agent_plan"):
         service.generate(
-            _request(kind=GeneratorKind.TOPIC_TABLE, candidates=["not allowed yet"])
+            _request(kind=GeneratorKind.AGENT_PLAN, candidates=["not allowed yet"])
         )
 
 

+ 233 - 0
tests/test_topic_table_query_generation.py

@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.api import app
+from app.routes import query_generation
+from query_planning.sources.pattern_topic_build import (
+    PatternTopicBuildConfig,
+    PatternTopicBuildSource,
+)
+from query_planning.topic_table import (
+    ROUTES,
+    TopicTableGenerationError,
+    generate_topic_table_preview,
+    preview_to_dict,
+)
+
+
+def _topic_payload() -> dict[str, Any]:
+    return {
+        "success": True,
+        "source_url": "https://pattern.aiddit.com/api/pattern/topic_builds/1229",
+        "build": {
+            "id": 1229,
+            "demand": "为小羊糊涂啊创作新选题",
+            "demand_constraints": None,
+            "strategies_config": {"always_on": []},
+            "status": "success",
+        },
+        "topics": [
+            {
+                "id": 1392,
+                "status": "mature",
+                "result": "樱花树下低角度拍少女与小羊,传递治愈感并分享穿搭。",
+                "topic_direction": {
+                    "account_name": "小羊糊涂啊",
+                    "persona_dimensions": {
+                        "实质偏好": "樱花与穿搭",
+                        "形式偏好": "低角度和动态摆拍",
+                        "意图偏好": "治愈和种草",
+                    },
+                },
+                "points": [
+                    {
+                        "id": 8371,
+                        "point_type": "灵感点",
+                        "point_result": "低角度仰拍小羊和彩虹光晕",
+                        "is_active": True,
+                        "item_ids": [25684, 25688],
+                    }
+                ],
+                "composition_items": [
+                    {
+                        "id": 25684,
+                        "item_level": "element",
+                        "dimension": "形式",
+                        "point_type": "灵感点",
+                        "element_name": "低角度仰拍",
+                        "category_path": "/呈现/视觉/低角度",
+                        "reason": "增强视觉张力",
+                        "is_active": True,
+                        "sources": [
+                            {
+                                "derivation_type": "external_search",
+                                "source_type": "search_case",
+                                "source_reference_id": "case-1",
+                                "reason": "草坪案例证明低角度有动感",
+                                "dataset_from": "",
+                                "source_reference_data": {"large": "must not enter prompt"},
+                            }
+                        ],
+                    },
+                    {
+                        "id": 25688,
+                        "item_level": "element",
+                        "dimension": "实质",
+                        "point_type": "灵感点",
+                        "element_name": "羊",
+                        "reason": "与账号名形成双关",
+                        "is_active": True,
+                        "sources": [],
+                    },
+                    {
+                        "id": 25700,
+                        "item_level": "element",
+                        "dimension": "意图",
+                        "point_type": "目的点",
+                        "element_name": "治愈感",
+                        "reason": "满足粉丝对美好生活的向往",
+                        "is_active": True,
+                        "sources": [],
+                    },
+                ],
+                "item_relations": [
+                    {
+                        "source_item_id": 25684,
+                        "target_item_id": 25688,
+                        "reason": "低角度用于突出人与小羊互动",
+                    }
+                ],
+            }
+        ],
+    }
+
+
+def _llm_output() -> dict[str, Any]:
+    needs = []
+    for index, (route, config) in enumerate(ROUTES.items()):
+        needs.append(
+            {
+                "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]},
+                "queries": [
+                    f"{config['label']}有哪些可执行方法",
+                    "逆光人像怎么拍出彩虹光晕" if index == 0 else f"{config['label']}常见失败原因",
+                ],
+            }
+        )
+    return {"knowledge_needs": needs}
+
+
+def test_topic_source_calls_read_only_detail_endpoint():
+    calls = []
+
+    class Response:
+        def raise_for_status(self):
+            return None
+
+        def json(self):
+            return _topic_payload()
+
+    source = PatternTopicBuildSource(
+        PatternTopicBuildConfig(base_url="https://pattern.example", timeout_seconds=8),
+        http_get=lambda url, **kwargs: calls.append((url, kwargs)) or Response(),
+    )
+
+    payload = source.fetch(1229)
+
+    assert calls == [
+        ("https://pattern.example/api/pattern/topic_builds/1229", {"timeout": 8})
+    ]
+    assert payload["build"]["id"] == 1229
+    assert payload["source_url"].endswith("/1229")
+
+
+def test_topic_table_generation_uses_six_routes_and_unified_budget():
+    seen = {}
+
+    def chat(system, user):
+        seen["system"] = system
+        seen["user"] = user
+        return _llm_output()
+
+    preview = generate_topic_table_preview(
+        _topic_payload(),
+        topic_id=1392,
+        max_queries=8,
+        chat_fn=chat,
+    )
+    data = preview_to_dict(preview)
+
+    assert data["generator_kind"] == "topic_table"
+    assert len(data["knowledge_needs"]) == 6
+    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 data["topic"]["dimensions"] == {
+        "实质": ["羊"],
+        "形式": ["低角度仰拍"],
+        "意图": ["治愈感"],
+    }
+    assert "source_reference_data" not in seen["user"]
+    assert "正好有 6 项" in seen["user"]
+    assert "废弃" not in seen["user"]
+    assert "What、How、Why" in seen["system"]
+    assert data["queries"][0]["route"] == "unique_hook"
+
+
+def test_topic_table_generation_rejects_missing_or_duplicate_routes():
+    malformed = _llm_output()
+    malformed["knowledge_needs"][-1]["route"] = "unique_hook"
+
+    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_prompt_and_preview_api_do_not_persist_or_search(monkeypatch):
+    class Source:
+        def fetch(self, topic_build_id):
+            assert topic_build_id == 1229
+            return _topic_payload()
+
+    monkeypatch.setattr(query_generation, "_topic_source", lambda _env: Source())
+    monkeypatch.setattr(
+        query_generation,
+        "_topic_query_chat",
+        lambda system, user, settings: _llm_output(),
+    )
+    monkeypatch.setattr(
+        query_generation.Settings,
+        "from_env",
+        classmethod(lambda cls, _env: object()),
+    )
+    client = TestClient(app)
+
+    prompt = client.get("/api/query-generation/topic-table/prompt")
+    assert prompt.status_code == 200
+    assert prompt.json()["example_request"]["topic_build_id"] == 1229
+    assert len(prompt.json()["routes"]) == 6
+
+    response = client.post(
+        "/api/query-generation/topic-table/preview",
+        json={"topic_build_id": 1229, "topic_id": 1392, "max_queries": 10},
+    )
+    assert response.status_code == 200
+    body = response.json()
+    assert body["source"]["topic_id"] == 1392
+    assert body["summary"]["selected_count"] == 10
+    assert body["summary"]["persisted"] is False
+    assert body["summary"]["search_started"] is False

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor