Преглед изворни кода

feat(app): add formal api and migrate frontend

SamLee пре 3 недеља
родитељ
комит
3bfcc10825

Разлика између датотеке није приказан због своје велике величине
+ 0 - 8
acquisition/web/app/dist/assets/index-wpGdM-2q.js


+ 0 - 235
acquisition/web/app/src/pages/CreationDemo.jsx

@@ -1,235 +0,0 @@
-import React, { useEffect, useState } from 'react'
-
-// 创作Query正交 Demo:仿"制作query"的多列 UI——左侧若干「轴列」,右侧「Query 词」。
-// 关键:左侧轴列只显示【该家族 query 实际用到的去重值】,与右侧 query 一一对应(不是整池)。
-// 阶段列只显示 灵感/选题/脚本 三个裸阶段;组合方式按尾缀分组展示。
-const PARTS_KEY = {       // 家族 axes 里的轴名 → 每条 query 的 parts 里的键
-  '实质': '实质', '形式': '形式', '作用/感受/意图': '目的',
-  '业务阶段': '业务阶段', '模态': '模态', '知识类型': '知识类型',
-  '阶段': '阶段', '动作': '动作', '作用': '作用',   // 第六家族(老正交)的轴
-}
-const AXIS_LABEL = {      // 列头显示用的标注(取值层级)
-  '实质': '实质(3-4层)', '形式': '形式(3-4层)',
-}
-const USED_ONLY = new Set(['实质', '形式'])   // 仅这两轴只显示这批 query 用到的去重值;其余轴显示完整池
-const POOL_KEY = {                            // 完整池轴名 → axis_values 键
-  '作用/感受/意图': '目的池', '模态': '模态', '知识类型': '知识类型', '业务阶段': '业务阶段',
-  '阶段': '阶段', '动作': '动作', '作用': '作用',   // 第六家族(老正交)的轴,显示完整池
-}
-
-// 该家族 query 在某轴上实际用过的去重值(按首次出现顺序)
-function usedValues(items, pk) {
-  const set = new Set(), out = []
-  for (const it of items) {
-    const v = it.parts && it.parts[pk]
-    if (v && !set.has(v)) { set.add(v); out.push(v) }
-  }
-  return out
-}
-
-function AxisCol({ ax, fam, axisValues }) {
-  let body, count
-  if (USED_ONLY.has(ax)) {
-    // 实质/形式:只显示这批 query 用到的去重值
-    const used = usedValues(fam.items, PARTS_KEY[ax])
-    count = used.length
-    body = used.map((v, i) => <div className="axv" key={i}>{v}</div>)
-  } else {
-    // 目的池/业务阶段/模态/知识类型/(老轴):完整池平铺
-    const pool = axisValues[POOL_KEY[ax]] || []
-    count = pool.length
-    body = pool.map((v, i) => <div className="axv" key={i}>{v}</div>)
-  }
-  return (
-    <div className="axcol">
-      <div className="axhd">{AXIS_LABEL[ax] || ax}<span className="gn">{count}</span></div>
-      <div className="axlist">{body}</div>
-    </div>
-  )
-}
-
-// 一栏家族(竖排按钮):按尾缀分组。按钮内前缀左对齐、尾缀(后 sufLen 个轴)右对齐。
-function FamCol({ title, fams, sufLen, fi, setFi }) {
-  return (
-    <div className="famcol">
-      <div className="famcol-hd">{title}</div>
-      {fams.map(f => {
-        const pre = f.axes.slice(0, f.axes.length - sufLen).join(' × ')
-        const suf = f.axes.slice(f.axes.length - sufLen).join(' × ')
-        return (
-          <button key={f.key} className={`fbtn ${f.i === fi ? 'on' : ''}`} onClick={() => setFi(f.i)}>
-            <span className="fbtn-pre">{pre}</span>
-            <span className="fbtn-suf">{suf}</span>
-          </button>
-        )
-      })}
-    </div>
-  )
-}
-
-function rowRank(f) {
-  const hasShi = f.axes.includes('实质')
-  const hasXing = f.axes.includes('形式')
-  const hasPurpose = f.axes.includes('作用/感受/意图')
-  if (hasShi && hasXing) return 3
-  if (hasShi) return 1
-  if (hasXing) return 2
-  if (hasPurpose) return 4
-  return 5
-}
-
-function byRowRank(a, b) {
-  return rowRank(a) - rowRank(b)
-}
-
-function averageCreationStats(items, summary) {
-  const rows = items
-    .map(it => summary[it.query])
-    .filter(Boolean)
-  if (!rows.length) return null
-  const creation = rows.reduce((s, info) => s + (info.imgtext_creation_count || 0), 0)
-  const target = rows[0]?.imgtext_target_count || 10
-  const complete = rows.filter(info => (info.imgtext_classified_count || 0) >= (info.imgtext_target_count || 10)).length
-  const avg = creation / rows.length
-  return {
-    label: Number.isInteger(avg) ? String(avg) : avg.toFixed(1),
-    target,
-    count: rows.length,
-    complete,
-  }
-}
-
-export default function CreationDemo() {
-  const [data, setData] = useState(null)
-  const [summary, setSummary] = useState({})
-  const [fi, setFi] = useState(0)
-  const [showFilter, setShowFilter] = useState(false)
-  useEffect(() => {
-    fetch('/data/queries/creation_demo.json?v=' + Date.now())
-      .then(r => r.ok ? r.json() : null).then(setData).catch(() => setData(null))
-    fetch('/api/creation-search/summary?v=' + Date.now())
-      .then(r => r.ok ? r.json() : null)
-      .then(d => setSummary((d && d.queries) || {}))
-      .catch(() => setSummary({}))
-  }, [])
-
-  if (!data) {
-    return <div className="empty">暂无 demo 数据。先跑 <code>scripts/build_creation_demo.py</code></div>
-  }
-  const fam = data.families[fi]
-  const kept = fam.items.filter(it => it.keep).length
-  const avgCreation = averageCreationStats(fam.items, summary)
-  const allFams = data.families.map((f, i) => ({ ...f, i }))
-  const modalFams = allFams
-    .filter(f => f.axes.includes('业务阶段'))
-    .sort(byRowRank)
-  const oldWithEffectFams = allFams
-    .filter(f => f.axes.includes('动作') && f.axes.includes('作用'))
-    .sort(byRowRank)
-  const oldNoEffectFams = allFams
-    .filter(f => f.axes.includes('动作') && !f.axes.includes('作用'))
-    .sort(byRowRank)
-
-  return (
-    <div>
-      <h1>创作知识 · Query 正交 Demo</h1>
-      <div className="sub">{data.families.length} 种组合方式机械正交(取分类树创作支)→ LLM 按提示词评 valid/relevant(query_filter)→ 原串直接可搜(本 demo 不真搜)· 每种组合方式最多 30 条全显示,✕ 删除线=被筛掉
-        <button className="btn ghost" style={{ marginLeft: 10 }} onClick={() => setShowFilter(true)}>📋 查看筛选提示词</button>
-      </div>
-
-      {showFilter && <FilterPromptModal onClose={() => setShowFilter(false)} />}
-
-      <div className="famcols">
-        <FamCol title="尾缀:模态 × 业务阶段 × 知识类型" sufLen={3}
-          fams={modalFams} fi={fi} setFi={setFi} />
-        <FamCol title="尾缀:阶段 × 动作 × 作用 × 知识类型" sufLen={4}
-          fams={oldWithEffectFams} fi={fi} setFi={setFi} />
-        <FamCol title="尾缀:阶段 × 动作 × 知识类型" sufLen={3}
-          fams={oldNoEffectFams} fi={fi} setFi={setFi} />
-      </div>
-
-      <div className="cdemo">
-        {fam.axes.map(ax => (
-          <AxisCol key={ax} ax={ax} fam={fam} axisValues={data.axis_values} />
-        ))}
-        <div className="axcol qcol">
-          <div className="axhd qhd">
-            <span>Query 词</span>
-            <span className="qhd-metrics">
-              {avgCreation && (
-                <span
-                  className={`avgcreation ${avgCreation.complete >= avgCreation.count ? 'done' : 'pending'}`}
-                  title={`按当前组 ${avgCreation.count} 个已有结果 query 计算,完整判断 ${avgCreation.complete}/${avgCreation.count}`}
-                >
-                  平均 {avgCreation.label}/{avgCreation.target}创作知识
-                </span>
-              )}
-              <span className="gn">留 {kept}/{fam.items.length}</span>
-            </span>
-          </div>
-          <div className="axlist">
-            {fam.items.map((it, i) => (
-              <div className={`qrow ${it.keep ? 'keep' : 'drop'}`} key={i} title={it.reason || ''}>
-                <span className="qmark">{it.keep ? '✓' : '✕'}</span>
-                {typeof it.valid === 'number' && (
-                  <span className={`qvalid ${it.valid >= 6 ? 'ok' : 'low'}`}>语义{it.valid}</span>
-                )}
-                <span className="qtext">{it.query}</span>
-                <QueryResultButton query={it.query} info={summary[it.query]} />
-                {!it.keep && <span className="qreason">{it.reason}</span>}
-              </div>
-            ))}
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-function QueryResultButton({ query, info }) {
-  const platforms = info?.platforms || {}
-  const counts = ['xiaohongshu', 'weixin', 'douyin']
-    .map(p => platforms[p]?.display_count || 0)
-  const label = info
-    ? `${counts.join('/')} 条`
-    : '未跑'
-  const cls = info ? 'qdetail on' : 'qdetail'
-  const judged = info?.imgtext_classified_count || 0
-  const target = info?.imgtext_target_count || 10
-  const creation = info?.imgtext_creation_count || 0
-  return (
-    <>
-      {info && (
-        <span
-          className={`qcreation ${judged >= target ? 'done' : 'pending'}`}
-          title={`小红书 + 微信公众号已判断 ${judged}/${target} 条`}
-        >
-          {creation}/{target}创作知识
-        </span>
-      )}
-      <a className={cls} href={`#/query/${encodeURIComponent(query)}`}>
-        查看结果<span>{label}</span>
-      </a>
-    </>
-  )
-}
-
-// 弹窗:展示机械正交后做 keep/排除 的创作筛选提示词(acquisition/query_filter.txt)
-function FilterPromptModal({ onClose }) {
-  const [p, setP] = useState(null)
-  useEffect(() => {
-    fetch('/api/filter-prompt').then(r => r.ok ? r.json() : null).then(setP).catch(() => setP(null))
-  }, [])
-  return (
-    <div className="modal-mask" onClick={onClose}>
-      <div className="modal wide" onClick={(e) => e.stopPropagation()}>
-        <div className="modal-hd"><b>创作 query 筛选提示词</b><span className="x" onClick={onClose}>✕</span></div>
-        <div className="modal-bd">
-          <p className="note">机械正交拼出的每条 query 逐条喂大模型,返回语义合法性 valid 与创作相关性 relevant;代码按 valid ≥ 6 且 relevant=true 保留。
-            判断口径全靠这条提示词。{p?.file && <code> · {p.file}</code>}</p>
-          <pre className="prompt">{p ? p.text : '加载中…'}</pre>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 1 - 0
app/__init__.py

@@ -0,0 +1 @@
+"""Formal API and frontend package."""

+ 52 - 0
app/api.py

@@ -0,0 +1,52 @@
+"""Formal FastAPI entrypoint for the creation-knowledge workbench."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+
+from app.routes import acquisition, decode, payloads, runs
+
+ROOT = Path(__file__).resolve().parent.parent
+FRONTEND_DIST = ROOT / "app" / "frontend" / "dist"
+
+
+def _cors_origins() -> list[str]:
+    raw = os.getenv("CK_CORS_ORIGINS", "")
+    if raw.strip():
+        return [item.strip() for item in raw.split(",") if item.strip()]
+    return [
+        "http://localhost:5173",
+        "http://127.0.0.1:5173",
+        "http://localhost:5180",
+        "http://127.0.0.1:5180",
+    ]
+
+
+def create_app() -> FastAPI:
+    app = FastAPI(title="创作知识发现解构工作台", version="0.6.0")
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=_cors_origins(),
+        allow_credentials=True,
+        allow_methods=["GET", "POST", "PATCH", "OPTIONS"],
+        allow_headers=["Authorization", "Content-Type"],
+    )
+    app.include_router(acquisition.router)
+    app.include_router(decode.router)
+    app.include_router(payloads.router)
+    app.include_router(runs.router)
+
+    @app.get("/health")
+    def health() -> dict[str, str]:
+        return {"status": "ok", "entrypoint": "app.api:app"}
+
+    if FRONTEND_DIST.exists():
+        app.mount("/app", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="app")
+    return app
+
+
+app = create_app()

+ 32 - 0
app/dependencies.py

@@ -0,0 +1,32 @@
+"""FastAPI dependencies for the formal cloud workbench."""
+from __future__ import annotations
+
+import os
+from typing import Iterator
+
+from fastapi import HTTPException
+
+from acquisition.repositories.postgres import PostgresAcquisitionRepository
+from core.config import CreationDbConfig
+from core.db_session import transaction
+
+
+def _env_file() -> str:
+    return os.getenv("CK_ENV_FILE", ".env")
+
+
+def get_creation_db_config() -> CreationDbConfig:
+    return CreationDbConfig.from_env(_env_file())
+
+
+def get_acquisition_repository() -> Iterator[PostgresAcquisitionRepository]:
+    with transaction(get_creation_db_config()) as conn:
+        yield PostgresAcquisitionRepository(conn)
+
+
+def get_decode_repository():
+    raise HTTPException(status_code=501, detail="decode repository is wired in pipeline Step 7")
+
+
+def get_pipeline_repository():
+    raise HTTPException(status_code=501, detail="pipeline repository persistence is wired in Step 7")

+ 0 - 0
acquisition/web/app/.gitignore → app/frontend/.gitignore


+ 0 - 0
acquisition/web/app/dist/assets/index-CkGW0KOY.css → app/frontend/dist/assets/index-CkGW0KOY.css


Разлика између датотеке није приказан због своје велике величине
+ 8 - 0
app/frontend/dist/assets/index-DEWR-s1T.js


+ 1 - 1
acquisition/web/app/dist/index.html → app/frontend/dist/index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>创作知识 · Query 正交 Demo</title>
-    <script type="module" crossorigin src="/app/assets/index-wpGdM-2q.js"></script>
+    <script type="module" crossorigin src="/app/assets/index-DEWR-s1T.js"></script>
     <link rel="stylesheet" crossorigin href="/app/assets/index-CkGW0KOY.css">
   </head>
   <body>

+ 0 - 0
acquisition/web/app/index.html → app/frontend/index.html


+ 2 - 2
acquisition/web/app/package-lock.json → app/frontend/package-lock.json

@@ -1,11 +1,11 @@
 {
-  "name": "acquisition-web",
+  "name": "creation-knowledge-workbench",
   "version": "0.1.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "acquisition-web",
+      "name": "creation-knowledge-workbench",
       "version": "0.1.0",
       "dependencies": {
         "react": "^18.3.1",

+ 2 - 2
acquisition/web/app/package.json → app/frontend/package.json

@@ -1,9 +1,9 @@
 {
-  "name": "acquisition-web",
+  "name": "creation-knowledge-workbench",
   "private": true,
   "version": "0.1.0",
   "type": "module",
-  "description": "创作知识 query 正交 demo 前端",
+  "description": "创作知识采集、解构与审核工作台前端",
   "scripts": {
     "dev": "vite",
     "build": "vite build",

+ 7 - 2
acquisition/web/app/src/App.jsx → app/frontend/src/App.jsx

@@ -5,7 +5,8 @@ import CreationQueryDetail from './pages/CreationQueryDetail.jsx'
 function parseRoute() {
   const hash = window.location.hash || '#/'
   if (hash.startsWith('#/query/')) {
-    return { name: 'query', query: decodeURIComponent(hash.slice('#/query/'.length)) }
+    const [, runId, queryId] = hash.match(/^#\/query\/([^/]+)\/([^/]+)/) || []
+    return { name: 'query', runId: decodeURIComponent(runId || ''), queryId: decodeURIComponent(queryId || '') }
   }
   return { name: 'demo' }
 }
@@ -19,7 +20,11 @@ export default function App() {
   }, [])
   return (
     <div className="wrap">
-      {route.name === 'query' ? <CreationQueryDetail query={route.query} /> : <CreationDemo />}
+      {route.name === 'query' ? (
+        <CreationQueryDetail runId={route.runId} queryId={route.queryId} />
+      ) : (
+        <CreationDemo />
+      )}
     </div>
   )
 }

+ 0 - 0
acquisition/web/app/src/main.jsx → app/frontend/src/main.jsx


+ 115 - 0
app/frontend/src/pages/CreationDemo.jsx

@@ -0,0 +1,115 @@
+import React, { useEffect, useMemo, useState } from 'react'
+
+function params() {
+  return new URLSearchParams(window.location.search)
+}
+
+function statusText(status) {
+  return {
+    pending: '等待中',
+    running: '运行中',
+    done: '完成',
+    partial: '部分完成',
+    failed: '失败',
+    skipped: '跳过',
+  }[status] || status || '未开始'
+}
+
+function querySummaryMap(summary) {
+  const out = new Map()
+  for (const row of summary?.queries || []) {
+    out.set(row.query_id, row)
+  }
+  return out
+}
+
+function platformCounts(row) {
+  const platforms = row?.platforms || {}
+  return ['xiaohongshu', 'weixin', 'douyin'].map((key) => platforms[key]?.status || '未跑').join(' / ')
+}
+
+export default function CreationDemo() {
+  const [batch, setBatch] = useState(null)
+  const [summary, setSummary] = useState(null)
+  const [err, setErr] = useState('')
+  const search = params()
+  const batchId = search.get('batch_id') || ''
+  const runId = search.get('run_id') || ''
+  const summaryByQuery = useMemo(() => querySummaryMap(summary), [summary])
+
+  useEffect(() => {
+    setErr('')
+    setBatch(null)
+    setSummary(null)
+    if (!batchId) {
+      return
+    }
+    fetch(`/api/query-batches/${encodeURIComponent(batchId)}`)
+      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('query batch 读取失败'))))
+      .then(setBatch)
+      .catch((e) => setErr(e.message || '读取失败'))
+    if (runId) {
+      fetch(`/api/acquisition/runs/${encodeURIComponent(runId)}/summary`)
+        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('run summary 读取失败'))))
+        .then(setSummary)
+        .catch(() => setSummary(null))
+    }
+  }, [batchId, runId])
+
+  if (!batchId) {
+    return (
+      <div>
+        <h1>创作知识采集工作台</h1>
+        <div className="empty">请在 URL 加上 <code>?batch_id=...</code>,可选 <code>&run_id=...</code> 查看正式云端批次。</div>
+      </div>
+    )
+  }
+  if (err) return <div className="empty">{err}</div>
+  if (!batch) return <div className="empty">加载中...</div>
+
+  const queries = batch.queries || []
+  return (
+    <div>
+      <h1>创作知识采集工作台</h1>
+      <div className="sub">
+        {batch.batch?.name || 'Query Batch'} · {queries.length} 条 query
+        {summary && <span> · 候选 {summary.candidate_count} · 命中 {summary.creation_hit_count}</span>}
+      </div>
+
+      <div className="cdemo single">
+        <div className="axcol qcol wide">
+          <div className="axhd qhd">
+            <span>Query</span>
+            <span className="qhd-metrics">
+              {runId && <span className="avgcreation">{statusText(summary?.status)}</span>}
+              <span className="gn">{queries.length}</span>
+            </span>
+          </div>
+          <div className="axlist">
+            {queries.map((query) => {
+              const qsum = summaryByQuery.get(query.id)
+              const href = runId ? `#/query/${encodeURIComponent(runId)}/${encodeURIComponent(query.id)}` : '#/'
+              return (
+                <div className={`qrow ${query.keep === false ? 'drop' : 'keep'}`} key={query.id} title={query.filter_reason || ''}>
+                  <span className="qmark">{query.keep === false ? '✕' : '✓'}</span>
+                  <span className="qtext">{query.query_text}</span>
+                  {qsum && (
+                    <span className="qcreation done">
+                      {qsum.creation_hit_count}/{qsum.candidate_count} 命中
+                    </span>
+                  )}
+                  {runId ? (
+                    <a className="qdetail on" href={href}>查看素材<span>{platformCounts(qsum)}</span></a>
+                  ) : (
+                    <span className="qdetail">待运行</span>
+                  )}
+                  {query.filter_reason && <span className="qreason">{query.filter_reason}</span>}
+                </div>
+              )
+            })}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 53 - 41
acquisition/web/app/src/pages/CreationQueryDetail.jsx → app/frontend/src/pages/CreationQueryDetail.jsx

@@ -11,11 +11,27 @@ function shortText(text, n = 220) {
   return text.length > n ? text.slice(0, n) + '...' : text
 }
 
+function mediaUrl(asset) {
+  return asset?.cdn_url || asset?.oss_url || asset?.source_url || ''
+}
+
+function itemImages(item) {
+  return (item.media_assets || [])
+    .filter((asset) => ['image', 'cover', 'frame'].includes(asset.media_type))
+    .map(mediaUrl)
+    .filter(Boolean)
+}
+
+function itemVideo(item) {
+  const asset = (item.media_assets || []).find((row) => row.media_type === 'video')
+  return mediaUrl(asset)
+}
+
 function clsLabel(cls) {
-  if (!cls || cls.is_creation === null || cls.is_creation === undefined) {
-    return ['none', cls?.error ? '判断失败' : '未判断']
+  if (!cls || cls.is_creation_knowledge === null || cls.is_creation_knowledge === undefined) {
+    return ['none', '未判断']
   }
-  return cls.is_creation ? ['yes', '创作知识'] : ['no', '非创作知识']
+  return cls.is_creation_knowledge ? ['yes', '创作知识'] : ['no', '非创作知识']
 }
 
 function PlatformStatus({ platform }) {
@@ -31,64 +47,57 @@ function PlatformStatus({ platform }) {
   return <span className={`status ${status}`}>{text}</span>
 }
 
-function Media({ item, platform, onImageClick }) {
-  if (platform === 'douyin' && item.video_url) {
-    return <video controls playsInline src={item.video_url} poster={item.cover_url || undefined} />
+function Media({ item, onImageClick }) {
+  const video = itemVideo(item)
+  if (video) {
+    return <video controls playsInline src={video} />
   }
-  const images = item.image_urls || []
+  const images = itemImages(item)
   if (images.length > 1) {
     return (
       <div className="thumbstrip" aria-label="帖子图片">
-        {images.map((u, i) => (
-          <button className="thumbbtn" key={i} type="button" onClick={() => onImageClick?.(images, i, item.title)}>
-            <img className="gimg" src={u} alt={`第 ${i + 1} 张`} loading="lazy" />
+        {images.map((url, i) => (
+          <button className="thumbbtn" key={url} type="button" onClick={() => onImageClick?.(images, i, item.title)}>
+            <img className="gimg" src={url} alt={`第 ${i + 1} 张`} loading="lazy" />
             <span>{i + 1}</span>
           </button>
         ))}
       </div>
     )
   }
-  const src = images[0] || item.cover_url
-  return src ? (
-    <button className="coverbtn" type="button" onClick={() => onImageClick?.([src], 0, item.title)}>
-      <img className="cover" src={src} alt="" loading="lazy" />
+  return images[0] ? (
+    <button className="coverbtn" type="button" onClick={() => onImageClick?.(images, 0, item.title)}>
+      <img className="cover" src={images[0]} alt="" loading="lazy" />
     </button>
   ) : null
 }
 
-function ResultCard({ item, platform, tone, onImageClick }) {
+function ResultCard({ item, tone, onImageClick }) {
   const [clsTone, clsText] = clsLabel(item.classification)
-  const hasKnowledge = Boolean(item.classification?.knowledge)
   return (
     <div className={`lane ${tone}`}>
-      <Media item={item} platform={platform} onImageClick={onImageClick} />
+      <Media item={item} onImageClick={onImageClick} />
       <div className="card-main">
         <div className="t">{item.title || '无标题'}</div>
         <span className={`cls ${clsTone}`}>{clsText}</span>
       </div>
-      {item.author && <div className="meta">{item.author}</div>}
+      {item.author_name && <div className="meta">{item.author_name}</div>}
       {item.classification?.reason && <div className="meta">{item.classification.reason}</div>}
-      {item.body_text && (
+      {item.raw_summary && (
         <details className="fold">
           <summary>正文摘要</summary>
-          <div className="bt">{shortText(item.body_text, 520)}</div>
-        </details>
-      )}
-      {hasKnowledge && (
-        <details className="fold knfold">
-          <summary>创作知识</summary>
-          <div className="knbody">{item.classification.knowledge}</div>
+          <div className="bt">{shortText(item.raw_summary, 520)}</div>
         </details>
       )}
-      {item.url && <a className="src" href={item.url} target="_blank" rel="noreferrer">打开原链接</a>}
+      {item.canonical_url && <a className="src" href={item.canonical_url} target="_blank" rel="noreferrer">打开原链接</a>}
     </div>
   )
 }
 
 function PlatformGroup({ id, label, tone, data, onImageClick }) {
   const items = data?.items || []
-  const creationItems = items.filter(item => item.classification?.is_creation === 1)
-  const otherItems = items.filter(item => item.classification?.is_creation !== 1)
+  const creationItems = items.filter((item) => item.classification?.is_creation_knowledge === true)
+  const otherItems = items.filter((item) => item.classification?.is_creation_knowledge !== true)
   const renderRow = (rowItems, title, dim = false) => (
     <div className="result-band">
       <div className={`subhd ${dim ? 'dim' : ''}`}>
@@ -99,7 +108,7 @@ function PlatformGroup({ id, label, tone, data, onImageClick }) {
         <div className="row-empty">{dim ? '暂无非创作知识或判断失败' : '暂无创作知识'}</div>
       ) : (
         <div className="grid result-row">
-          {rowItems.map(item => <ResultCard key={item.id} item={item} platform={id} tone={tone} onImageClick={onImageClick} />)}
+          {rowItems.map((item) => <ResultCard key={item.id} item={item} tone={tone} onImageClick={onImageClick} />)}
         </div>
       )}
     </div>
@@ -109,9 +118,8 @@ function PlatformGroup({ id, label, tone, data, onImageClick }) {
       <div className={`ghead ${tone}`}>
         <span>{label}</span>
         <PlatformStatus platform={data} />
-        <span className="gn">{items.length}/5 条</span>
+        <span className="gn">{items.length} 条</span>
       </div>
-      {data?.error && <div className="perr">{data.error}</div>}
       {items.length === 0 ? (
         <div className="gnone">暂无可展示内容</div>
       ) : (
@@ -124,7 +132,7 @@ function PlatformGroup({ id, label, tone, data, onImageClick }) {
   )
 }
 
-export default function CreationQueryDetail({ query }) {
+export default function CreationQueryDetail({ runId, queryId }) {
   const [data, setData] = useState(null)
   const [err, setErr] = useState('')
   const [lightbox, setLightbox] = useState(null)
@@ -134,7 +142,7 @@ export default function CreationQueryDetail({ query }) {
   }
 
   const moveImage = (delta) => {
-    setLightbox(prev => {
+    setLightbox((prev) => {
       if (!prev) return prev
       const next = (prev.index + delta + prev.images.length) % prev.images.length
       return { ...prev, index: next }
@@ -144,19 +152,23 @@ export default function CreationQueryDetail({ query }) {
   useEffect(() => {
     setData(null)
     setErr('')
-    fetch('/api/creation-search/query?display_limit=5&query=' + encodeURIComponent(query))
-      .then(r => r.ok ? r.json() : Promise.reject(new Error('接口读取失败')))
+    if (!runId || !queryId) {
+      setErr('缺少 run_id 或 query_id')
+      return
+    }
+    fetch(`/api/acquisition/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(queryId)}`)
+      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('接口读取失败'))))
       .then(setData)
-      .catch(e => setErr(e.message || '读取失败'))
-  }, [query])
+      .catch((e) => setErr(e.message || '读取失败'))
+  }, [runId, queryId])
 
   return (
     <div>
       <div className="detail-top">
-        <a className="back" href="#/">返回 Query Demo</a>
-        {data?.run_id && <span className="meta">run_id: {data.run_id}</span>}
+        <a className="back" href="#/">返回 Query 列表</a>
+        {runId && <span className="meta">run_id: {runId}</span>}
       </div>
-      <h1 className="dq">{query}</h1>
+      <h1 className="dq">{data?.query?.query_text || queryId}</h1>
       {err && <div className="empty">{err}</div>}
       {!data && !err && <div className="empty">加载中...</div>}
       {data && PLATFORMS.map(([id, label, tone]) => (

+ 0 - 0
acquisition/web/app/src/styles.css → app/frontend/src/styles.css


+ 2 - 4
acquisition/web/app/vite.config.js → app/frontend/vite.config.js

@@ -1,8 +1,8 @@
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
 
-// 构建产物挂在 FastAPI 的 /app/ 下(base 必须对齐),旧看板仍占 /
-// dev 时把数据/媒体请求代理到本地 uvicorn(8126),省去跨域。
+// 构建产物挂在 FastAPI 的 /app/ 下(base 必须对齐)。
+// dev 时把正式 API 请求代理到本地 uvicorn,省去跨域。
 export default defineConfig({
   base: '/app/',
   plugins: [react()],
@@ -11,8 +11,6 @@ export default defineConfig({
     port: 5180,
     proxy: {
       '/api': 'http://127.0.0.1:8126',
-      '/data': 'http://127.0.0.1:8126',
-      '/frames': 'http://127.0.0.1:8126',
     },
   },
 })

+ 5 - 0
app/routes/__init__.py

@@ -0,0 +1,5 @@
+"""Formal API route modules."""
+
+from app.routes import acquisition, decode, payloads, runs
+
+__all__ = ["acquisition", "decode", "payloads", "runs"]

+ 100 - 0
app/routes/acquisition.py

@@ -0,0 +1,100 @@
+"""Formal acquisition API routes."""
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends
+
+from acquisition.repositories.base import AcquisitionRepository
+from app.dependencies import get_acquisition_repository
+from app.schemas import (
+    AcquisitionRunSummarySchema,
+    CandidateItemSchema,
+    ItemClassificationSchema,
+    MediaAssetSchema,
+    QueryBatchSchema,
+    QuerySchema,
+)
+
+router = APIRouter(prefix="/api", tags=["acquisition"])
+
+
+def _model_dump(row: Any) -> dict[str, Any]:
+    if isinstance(row, dict):
+        return dict(row)
+    if hasattr(row, "model_dump"):
+        return row.model_dump()
+    return dict(row)
+
+
+def _normalize_query_detail(detail: dict[str, Any]) -> dict[str, Any]:
+    media_by_item: dict[Any, list[dict[str, Any]]] = defaultdict(list)
+    for media in detail.get("media_assets") or []:
+        media_by_item[media["item_id"]].append(media)
+
+    classification_by_item: dict[Any, dict[str, Any]] = {}
+    for row in detail.get("classifications") or []:
+        classification_by_item.setdefault(row["item_id"], row)
+
+    items: list[dict[str, Any]] = []
+    platforms: dict[str, dict[str, Any]] = {}
+    for row in detail.get("items") or []:
+        item = _model_dump(row)
+        item["media_assets"] = [
+            MediaAssetSchema.model_validate(media).model_dump(mode="json")
+            for media in media_by_item.get(item["id"], [])
+        ]
+        cls = classification_by_item.get(item["id"])
+        item["classification"] = (
+            ItemClassificationSchema.model_validate(cls).model_dump(mode="json")
+            if cls
+            else None
+        )
+        payload = CandidateItemSchema.model_validate(item).model_dump(mode="json")
+        items.append(payload)
+        group = platforms.setdefault(
+            payload["platform"],
+            {"platform": payload["platform"], "status": "done", "items": []},
+        )
+        group["items"].append(payload)
+    return {
+        "query": QuerySchema.model_validate(detail["query"]).model_dump(mode="json"),
+        "jobs": [_model_dump(job) for job in detail.get("jobs") or []],
+        "items": items,
+        "platforms": platforms,
+    }
+
+
+@router.get("/query-batches/{batch_id}")
+def query_batch(
+    batch_id: UUID,
+    repo: AcquisitionRepository = Depends(get_acquisition_repository),
+) -> dict[str, Any]:
+    batch = repo.get_query_batch(batch_id)
+    queries = repo.list_queries_for_batch(batch_id)
+    return {
+        "batch": QueryBatchSchema.model_validate(batch).model_dump(mode="json"),
+        "queries": [QuerySchema.model_validate(query).model_dump(mode="json") for query in queries],
+    }
+
+
+@router.get(
+    "/acquisition/runs/{run_id}/summary",
+    response_model=AcquisitionRunSummarySchema,
+)
+def acquisition_run_summary(
+    run_id: UUID,
+    repo: AcquisitionRepository = Depends(get_acquisition_repository),
+) -> AcquisitionRunSummarySchema:
+    return AcquisitionRunSummarySchema.model_validate(repo.get_run_summary(run_id))
+
+
+@router.get("/acquisition/runs/{run_id}/queries/{query_id}")
+def acquisition_query_detail(
+    run_id: UUID,
+    query_id: UUID,
+    repo: AcquisitionRepository = Depends(get_acquisition_repository),
+) -> dict[str, Any]:
+    return _normalize_query_detail(repo.get_query_detail(run_id=run_id, query_id=query_id))

+ 35 - 0
app/routes/decode.py

@@ -0,0 +1,35 @@
+"""Formal decode API routes."""
+from __future__ import annotations
+
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.dependencies import get_decode_repository
+from app.schemas import DecodeResultSchema
+
+router = APIRouter(prefix="/api/decode", tags=["decode"])
+
+
+@router.get("/items/{item_id}/result", response_model=DecodeResultSchema)
+def decode_result(
+    item_id: UUID,
+    repo: Any = Depends(get_decode_repository),
+) -> DecodeResultSchema:
+    getter = getattr(repo, "get_decode_result_for_item", None)
+    if getter is None:
+        raise HTTPException(status_code=501, detail="decode result reader is not implemented")
+    return DecodeResultSchema.model_validate(getter(item_id))
+
+
+@router.post("/items/{item_id}/jobs")
+def enqueue_decode_job(
+    item_id: UUID,
+    repo: Any = Depends(get_decode_repository),
+) -> dict[str, Any]:
+    create = getattr(repo, "create_decode_job", None)
+    if create is None:
+        raise HTTPException(status_code=501, detail="decode job writer is not implemented")
+    job = create(item_id=item_id, status="pending")
+    return {"job": job.model_dump(mode="json") if hasattr(job, "model_dump") else job}

+ 29 - 0
app/routes/payloads.py

@@ -0,0 +1,29 @@
+"""Formal payload draft API routes."""
+from __future__ import annotations
+
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from app.dependencies import get_decode_repository
+from app.schemas import PayloadDraftSchema
+
+router = APIRouter(prefix="/api/payloads", tags=["payloads"])
+
+
+@router.get("/drafts")
+def payload_drafts(
+    item_id: UUID | None = Query(default=None),
+    repo: Any = Depends(get_decode_repository),
+) -> dict[str, Any]:
+    list_drafts = getattr(repo, "list_payload_drafts", None)
+    if list_drafts is None:
+        raise HTTPException(status_code=501, detail="payload draft reader is not implemented")
+    rows = list_drafts(item_id=item_id)
+    return {
+        "items": [
+            PayloadDraftSchema.model_validate(row).model_dump(mode="json")
+            for row in rows
+        ]
+    }

+ 23 - 0
app/routes/runs.py

@@ -0,0 +1,23 @@
+"""Formal pipeline run API routes."""
+from __future__ import annotations
+
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.dependencies import get_pipeline_repository
+
+router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
+
+
+@router.get("/runs/{run_id}")
+def pipeline_run(
+    run_id: UUID,
+    repo: Any = Depends(get_pipeline_repository),
+) -> dict[str, Any]:
+    getter = getattr(repo, "get_pipeline_run", None)
+    if getter is None:
+        raise HTTPException(status_code=501, detail="pipeline run reader is not implemented")
+    run = getter(run_id)
+    return {"run": run.model_dump(mode="json") if hasattr(run, "model_dump") else run}

+ 150 - 0
app/schemas.py

@@ -0,0 +1,150 @@
+"""Pydantic schemas returned by the formal workbench API."""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ApiSchema(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+
+class Page(ApiSchema):
+    total: int
+    page: int = 1
+    size: int = 50
+    items: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ErrorResponse(ApiSchema):
+    code: str
+    message: str
+    detail: dict[str, Any] = Field(default_factory=dict)
+
+
+class QueryBatchSchema(ApiSchema):
+    id: UUID
+    name: str
+    source_type: str
+    generation_method: str | None = None
+    target_platforms: list[str] = Field(default_factory=list)
+    status: str
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class QuerySchema(ApiSchema):
+    id: UUID
+    batch_id: UUID | None = None
+    query_text: str
+    axes: dict[str, Any] = Field(default_factory=dict)
+    keep: bool | None = None
+    filter_reason: str | None = None
+    status: str
+    sort_order: int = 0
+
+
+class MediaAssetSchema(ApiSchema):
+    id: UUID
+    item_id: UUID
+    media_type: str
+    source_url: str | None = None
+    oss_url: str | None = None
+    cdn_url: str | None = None
+    position: int = 0
+    status: str
+
+
+class ItemClassificationSchema(ApiSchema):
+    id: UUID
+    item_id: UUID
+    is_creation_knowledge: bool | None = None
+    label: str | None = None
+    confidence: float | None = None
+    reason: str | None = None
+    status: str
+
+
+class CandidateItemSchema(ApiSchema):
+    id: UUID
+    platform: str
+    platform_item_id: str | None = None
+    canonical_url: str | None = None
+    content_type: str | None = None
+    title: str | None = None
+    author_name: str | None = None
+    raw_summary: str | None = None
+    status: str
+    media_assets: list[MediaAssetSchema] = Field(default_factory=list)
+    classification: ItemClassificationSchema | None = None
+
+
+class AcquisitionRunSummarySchema(ApiSchema):
+    id: UUID
+    run_key: str | None = None
+    batch_id: UUID | None = None
+    status: str
+    query_count: int = 0
+    job_count: int = 0
+    candidate_count: int = 0
+    creation_hit_count: int = 0
+    queries: list[dict[str, Any]] = Field(default_factory=list)
+    started_at: datetime | None = None
+    finished_at: datetime | None = None
+
+
+class DecodeResultSchema(ApiSchema):
+    id: UUID
+    item_id: UUID
+    status: str
+    read_result: dict[str, Any] = Field(default_factory=dict)
+    gate_result: dict[str, Any] = Field(default_factory=dict)
+    framing_result: dict[str, Any] = Field(default_factory=dict)
+
+
+class KnowledgeParticleSchema(ApiSchema):
+    id: UUID
+    decode_result_id: UUID | None = None
+    item_id: UUID | None = None
+    parent_particle_id: UUID | None = None
+    particle_type: str
+    title: str
+    business_stage: str | None = None
+    creation_stage: str | None = None
+    content: dict[str, Any] = Field(default_factory=dict)
+    status: str
+
+
+class ScopeResultSchema(ApiSchema):
+    id: UUID
+    particle_id: UUID | None = None
+    item_id: UUID | None = None
+    scope_type: str
+    scope_value: str
+    is_reused: bool | None = None
+    matched_scope_id: str | None = None
+    confidence: float | None = None
+    status: str
+
+
+class PayloadDraftSchema(ApiSchema):
+    id: UUID
+    particle_id: UUID | None = None
+    item_id: UUID | None = None
+    payload: dict[str, Any] = Field(default_factory=dict)
+    review_status: str
+    ingest_ready: bool
+    status: str
+
+
+class IngestRecordSchema(ApiSchema):
+    id: UUID
+    payload_draft_id: UUID | None = None
+    target_system: str
+    target_id: str | None = None
+    status: str
+    attempt_count: int = 0
+    error_message: str | None = None

Неке датотеке нису приказане због велике количине промена