Просмотр исходного кода

feat(mode_workflow): 新增全量帖子接口、搜索进度、视频代理与工具解构表格

更新.claude/settings.local.json,新增常用Bash命令白名单
在db.py新增全量帖子查询和搜索统计函数
优化前端视频播放逻辑,适配YouTube并添加跨站视频代理
新增工具解构表格渲染组件,完善搜索结果展示
完善server.py新增三个API接口,修复连接中断错误处理
新增工序接口文档补充项目使用说明
刘文武 3 дней назад
Родитель
Сommit
dd4d74541f

+ 31 - 1
.claude/settings.local.json

@@ -107,7 +107,37 @@
       "Bash(agent-browser wait *)",
       "Bash(agent-browser screenshot *)",
       "Bash(agent-browser close *)",
-      "Bash(agent-browser open *)"
+      "Bash(agent-browser open *)",
+      "Bash(ffmpeg -version)",
+      "Bash(break)",
+      "Bash(yt-dlp -f \"best[ext=mp4]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best\" -o \"/private/tmp/claude-501/-Users-max-liu-max-liu-company-Agent/c4d85458-f0e1-47ac-bcff-f39776f2c548/scratchpad/yt_test.%\\(ext\\)s\" \"https://www.youtube.com/watch?v=7A-94cw-iw0\")",
+      "Bash(pkill -f \"yt_test\")",
+      "Bash(rm -f /private/tmp/claude-501/-Users-max-liu-max-liu-company-Agent/c4d85458-f0e1-47ac-bcff-f39776f2c548/scratchpad/yt_test.*)",
+      "Bash(timeout 60 yt-dlp --no-warnings -F \"https://www.youtube.com/watch?v=7A-94cw-iw0\")",
+      "Bash(yt-dlp --version)",
+      "Bash(yt-dlp -v -F \"https://www.youtube.com/watch?v=7A-94cw-iw0\")",
+      "Bash(yt-dlp -f \"bestaudio[ext=m4a]/bestaudio\" -o \"ytaudio.%\\(ext\\)s\" --no-playlist --no-warnings \"https://www.youtube.com/watch?v=7A-94cw-iw0\")",
+      "Bash(PYTHONPATH=/Users/max_liu/max_liu/company/Agent python /private/tmp/claude-501/-Users-max-liu-max-liu-company-Agent/c4d85458-f0e1-47ac-bcff-f39776f2c548/scratchpad/yt_retest.py)",
+      "Bash([ -f \"$f\" ])",
+      "Bash(lsof -nP -iTCP:8772 -sTCP:LISTEN)",
+      "Bash(PORT=8799 python server.py 8799)",
+      "Bash(echo \"temp server pid $!\")",
+      "Bash(cd /Users/max_liu/max_liu/money/LTL/LTL-Channel)",
+      "Bash(ls -la app/)",
+      "Bash(awk '/def normalize_detail_data/,/^CHANNEL_CONFIG|^def [a-z]/' app/crawler/normalizer.py)",
+      "Bash(.venv/bin/python -c ' *)",
+      "Bash(.venv/bin/uvicorn app.main:app --port 8899 --log-level warning)",
+      "Bash(echo \"pid $!\")",
+      "Bash(cd *)",
+      "Bash(awk '{f+=$1; i+=$4; d+=$6} END {print \"files touched \\(sum\\):\", f, \"| insertions:\", i, \"| deletions:\", d}')",
+      "Bash(.venv/bin/python -)",
+      "Bash(.venv/bin/python -m py_compile app/db.py app/models.py app/repository.py app/views.py app/evaluator.py)",
+      "Bash(.venv/bin/python -c \"from app.db import init_db; init_db\\(\\); print\\('init_db OK — llm_evaluation 列已确保'\\)\")",
+      "Bash(kill 78141)",
+      "Bash(.venv/bin/python -m py_compile app/views.py app/repository.py)",
+      "Bash(.venv/bin/python -m py_compile app/transcription.py app/evaluator.py app/views.py app/repository.py app/db.py app/models.py)",
+      "Bash(.venv/bin/python -c \"from app.db import init_db; init_db\\(\\); print\\('OK — video_transcript 列已确保'\\)\")",
+      "Bash(git check-ignore *)"
     ],
     "deny": [],
     "ask": []

+ 64 - 0
examples/mode_workflow/db.py

@@ -582,6 +582,70 @@ def fetch_post(query_id, case_id, table="search_process"):
     return row
 
 
+def fetch_all_posts(mode="process", *, adopted_only=False, distinct=False,
+                    limit=None, offset=0):
+    """某方向「全部帖子」:跨所有 query 的列表(瘦身列,口径同 fetch_posts,不拉
+    body/videos/llm_evaluation 大字段)。fetch_posts 限定单 query,本函数取全表。
+      - adopted_only=True:只返回采纳帖(is_adopted_rel 口径,rel/repro 由
+        _REL_SQL/_REPRO_SQL 直取标量算,不拉整表 blob)。
+      - distinct=True:按 case_id 去重(同一帖被多个 query 搜到时,只保留
+        overall_score 最高的一行——已按 score 降序,取首次出现即最高分)。
+      - limit/offset:分页(limit=None 不分页)。
+    返回 (total, rows):total 为过滤(+去重)后的总条数,rows 为本页切片。"""
+    table = _search_table(mode)
+    conn = _conn()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(f"""SELECT id, query_id, query_text, case_id, platform, channel_content_id,
+                                   title, url, content_type, images, like_count, publish_time,
+                                   quality_score, quality_grade, found_by, knowledge_type, overall_score,
+                                   {_REL_SQL} AS rel, {_REPRO_SQL} AS repro
+                            FROM {table}
+                            ORDER BY overall_score DESC, id""")
+            rows = cur.fetchall()
+            # has_process/has_tools 全局判定:跨 query 的「该帖是否已解构」,两张解构表各取一次
+            cur.execute("SELECT DISTINCT case_id FROM mode_process")
+            hp = {r["case_id"] for r in cur.fetchall()}
+            cur.execute("SELECT DISTINCT case_id FROM mode_tools")
+            ht = {r["case_id"] for r in cur.fetchall()}
+    finally:
+        conn.close()
+    out, seen = [], set()
+    for r in rows:
+        for col in ("images", "found_by", "knowledge_type"):
+            r[col] = _loads(r[col])
+        r["adopted"] = is_adopted_rel(r["overall_score"], r.pop("rel", None),
+                                      r["publish_time"], r.pop("repro", None))
+        if adopted_only and not r["adopted"]:
+            continue
+        if distinct:
+            if r["case_id"] in seen:
+                continue
+            seen.add(r["case_id"])
+        r["has_process"] = r["case_id"] in hp
+        r["has_tools"] = r["case_id"] in ht
+        out.append(r)
+    total = len(out)
+    if limit is not None:
+        out = out[offset:offset + limit]
+    elif offset:
+        out = out[offset:]
+    return total, out
+
+
+def count_executed_queries(mode="process"):
+    """该方向「已执行」的 query 数 = 搜索表里出现过的 distinct query_id 个数。
+    注:一次搜索若 0 命中则不写任何行,故不计入(口径为「已产出结果的 query」)。"""
+    table = _search_table(mode)
+    conn = _conn()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(f"SELECT COUNT(DISTINCT query_id) AS n FROM {table}")
+            return cur.fetchone()["n"]
+    finally:
+        conn.close()
+
+
 # ── mode_process ─────────────────────────────────────────────────────────────
 
 def replace_process(query_id, case_id, platform, post_title, payload,

+ 48 - 4
examples/mode_workflow/index.html

@@ -1374,6 +1374,22 @@
         border-radius: 10px;
         background: #000;
       }
+      .pd-videos iframe {
+        width: 100%;
+        aspect-ratio: 16 / 9;
+        border: 1px solid var(--line);
+        border-radius: 10px;
+        background: #000;
+      }
+      .pd-videos .vid-fallback {
+        display: block;
+        padding: 10px 12px;
+        font-size: 12px;
+        color: var(--accent, #2d6cdf);
+        border: 1px dashed var(--line);
+        border-radius: 10px;
+        text-decoration: none;
+      }
       /* ── 图片预览灯箱 ── */
       dialog.lightbox {
         position: fixed;
@@ -2543,6 +2559,26 @@
          就把本机上行打满,于是「本地快、公网慢」。改为浏览器直连后,绝大多数图片字节不再过本机/
          隧道,只有少数仍被防盗链拦截的才回退代理。 */
       const imgDirect = (u) => (/^https?:\/\//.test(u || "") ? u : imgProxy(u));
+      /* 视频走专用同源反代 /api/video:服务端按平台补 Referer(抖音 aweme/play 等必需)
+         + 透传 Range 头做边下边播/拖拽。不像 /api/img 那样整文件缓冲,避免慢 + BrokenPipe。 */
+      const vidProxy = (u) => (/^https?:\/\//.test(u || "") ? "/api/video?u=" + encodeURIComponent(u) : u || "");
+      /* 从各种 YouTube 链接里抠出 video_id(watch?v= / youtu.be/ / embed/) */
+      const ytId = (u) => {
+        const m =
+          (u || "").match(/[?&]v=([\w-]{6,})/) ||
+          (u || "").match(/youtu\.be\/([\w-]{6,})/) ||
+          (u || "").match(/embed\/([\w-]{6,})/);
+        return m ? m[1] : "";
+      };
+      /* 视频代理也失败时,把 <video> 换成「源站打开」链接(data-src 存原始 URL) */
+      function vidFallback(el) {
+        el.onerror = null;
+        const u = el.dataset.src || "";
+        if (u)
+          el.outerHTML =
+            '<a class="vid-fallback" href="' + u.replace(/"/g, "&quot;") +
+            '" target="_blank" rel="noopener">源站打开视频 ↗</a>';
+      }
       function imgFallback(el, proxy) {
         el.onerror = null; // 防止回调里再次触发自身造成死循环
         if (!el.dataset.fb) {
@@ -3166,10 +3202,18 @@
         pdVideos.style.display = showVids ? "" : "none";
         pdVideos.innerHTML = showVids
           ? vids
-              .map(
-                (s) =>
-                  `<video src="${esc(imgDirect(s))}" referrerpolicy="no-referrer" controls preload="metadata" playsinline onerror="this.onerror=null;this.src='${esc(imgProxy(s))}'"></video>`,
-              )
+              .map((s) => {
+                // YouTube 的 videos[0] 是 watch 页 URL,<video> 播不了 → 用 iframe embed
+                const isYt = p.platform === "youtube" || /youtube\.com|youtu\.be/.test(s);
+                if (isYt) {
+                  const id = p.video_id || ytId(s);
+                  return id
+                    ? `<iframe src="https://www.youtube.com/embed/${esc(id)}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`
+                    : `<a class="vid-fallback" href="${esc(s)}" target="_blank" rel="noopener">在 YouTube 打开视频 ↗</a>`;
+                }
+                // 其他平台(抖音等)走同源视频代理:补 Referer + Range 流式,失败兜底源站链接
+                return `<video src="${esc(vidProxy(s))}" data-src="${esc(s)}" controls preload="metadata" playsinline onerror="vidFallback(this)"></video>`;
+              })
               .join("")
           : "";
         $("#pd-tags").innerHTML = [

+ 122 - 4
examples/mode_workflow/search.html

@@ -381,6 +381,46 @@
     .steps td.c-out .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(238,243,254,0), rgba(238,243,254,1)); }
     .clamp-val.open { max-height: none; overflow: visible; cursor: zoom-out; }
     .clamp-val.open::after { display: none; }
+
+    /* ── 工具解构表(移植自 index.html renderTools:案例逐行 rowspan + 限高展开)─ */
+    .mw-ttwrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--border); border-radius: 8px; }
+    .mw-tt { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 1180px; background: #fff; font-size: 12.5px; }
+    .mw-tt thead th {
+      text-align: left; white-space: nowrap;
+      background: linear-gradient(180deg, #2aa79b, #1c8076); color: #fff; font-weight: 700;
+      padding: 9px 12px; letter-spacing: .3px; border-right: 1px solid rgba(255,255,255,.18);
+    }
+    .mw-tt thead th:last-child { border-right: none; }
+    .mw-tt .th-group { text-align: center; }
+    .mw-tt .th-sub { background: linear-gradient(180deg, #36bdb0, #23897f); font-weight: 600; }
+    .mw-tt tbody td {
+      padding: 9px 12px; vertical-align: top; line-height: 1.6;
+      border-bottom: 1px solid #f0eee8; border-right: 1px solid #f5f3ee; color: #3a3a3a;
+    }
+    .mw-tt tbody td:last-child { border-right: none; }
+    .mw-tt td.col-case { background: #fafdfc; }
+    .mw-tt tbody td.col-tool {
+      font-weight: 700; color: #176d64; white-space: nowrap;
+      border-left: 3px solid #2aa79b; background: #f3faf8;
+    }
+    .mw-tt ul { margin: 0; padding-left: 17px; }
+    .mw-tt ul li { margin: 3px 0; }
+    .mw-tt ul li::marker { color: #2aa79b; }
+    .mw-tt .layer-badge { display: inline-block; font-weight: 700; font-size: 11px; padding: 2px 10px; border-radius: 20px; white-space: nowrap; }
+    .mw-tt .layer-badge.make { color: #0e7490; background: #d6f0ee; }
+    .mw-tt .layer-badge.create { color: #b8731a; background: #fef0db; }
+    .mw-tt .dash { color: #c9c2b6; }
+    .mw-tt .tcell { position: relative; max-height: 7.8em; overflow: hidden; transition: max-height .15s; }
+    .mw-tt .tcell.clamped { cursor: zoom-in; }
+    .mw-tt .tcell.clamped::after {
+      content: '▾ 展开'; position: absolute; left: 0; right: 0; bottom: 0; height: 2.6em;
+      display: flex; align-items: flex-end; justify-content: center; padding-bottom: 2px;
+      font-size: 11px; font-weight: 700; color: #176d64;
+      background: linear-gradient(rgba(255,255,255,0), #fff 72%); pointer-events: none;
+    }
+    .mw-tt td.col-case .tcell.clamped::after { background: linear-gradient(rgba(250,253,252,0), #fafdfc 72%); }
+    .mw-tt .tcell.open { max-height: none; cursor: zoom-out; }
+    .mw-tt .tcell.open::after { content: ''; height: 0; }
   </style>
 </head>
 <body>
@@ -859,6 +899,7 @@ function renderResults(data) {
   } else {
     list.innerHTML = data.hits.map(renderCard).join('');
     requestAnimationFrame(markStepClamps);
+    requestAnimationFrame(markToolClamps);
   }
 
   renderPagination(data.total, data.page, data.page_size);
@@ -936,6 +977,82 @@ function renderSteps(steps) {
     </thead><tbody>${rows}</tbody></table></div>`;
 }
 
+// ── 工具解构表(自 index.html renderTools 移植:单条 content 即一个 tool 对象)──
+const TT_DASH = '<span class="dash">—</span>';
+function _ttToolCell(v) {
+  if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) return TT_DASH;
+  if (Array.isArray(v)) return '<ul>' + v.map((x) => `<li>${esc(String(x))}</li>`).join('') + '</ul>';
+  return esc(String(v));
+}
+function _ttScopeCell(v) {
+  if (v === null || v === undefined || (Array.isArray(v) && !v.length) || v === '') return TT_DASH;
+  return esc(Array.isArray(v) ? v.join('、') : String(v));
+}
+function _ttWrap(inner, clampable) {
+  return clampable ? `<div class="tcell" onclick="this.classList.toggle('open')">${inner}</div>` : inner;
+}
+function _ttCellContent(c, t) {
+  let inner, cls = '', clampable = true, style = '';
+  if (c === '工具名称') {
+    cls = 'col-tool'; clampable = false;
+    inner = `🔧 ${esc(t[c] || '(未命名)')}`;
+  } else if (c === '来源链接') {
+    clampable = false;
+    inner = t[c] ? `<a href="${esc(t[c])}" target="_blank" style="color:#176d64;font-weight:600;">🔗 打开</a>` : TT_DASH;
+  } else if (c === '创作层级') {
+    clampable = false;
+    inner = t[c] ? `<span class="layer-badge ${t[c] === '制作层' ? 'make' : 'create'}">${esc(t[c])}</span>` : TT_DASH;
+  } else if (c === '实质作用域' || c === '形式作用域') {
+    inner = _ttScopeCell(t[c]);
+  } else {
+    inner = _ttToolCell(t[c]);
+  }
+  if (['输入', '输出', '用法', '缺点'].includes(c)) style = 'max-width:240px;';
+  else if (c === '实质作用域' || c === '形式作用域') style = 'max-width:170px;';
+  else if (!clampable) style = 'white-space:nowrap;';
+  return { inner, cls, clampable, style };
+}
+function _ttTd(c, t, rowspan) {
+  const { inner, cls, clampable, style } = _ttCellContent(c, t);
+  const rs = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
+  return `<td class="${cls}" style="${style}"${rs}>${_ttWrap(inner, clampable)}</td>`;
+}
+function _ttCaseTd(cse, key) {
+  const v = cse && cse[key] != null && cse[key] !== '' ? esc(String(cse[key])) : TT_DASH;
+  return `<td class="col-case" style="max-width:210px;">${_ttWrap(v, true)}</td>`;
+}
+// content 是单个工具对象 → 渲染成一行(案例多条则逐行 rowspan)的工具解构表
+function renderToolTable(tool) {
+  const before = ['工具名称', '创作层级', '实质作用域', '形式作用域', '输入', '输出', '用法'];
+  const after = ['缺点', '来源链接', '最新更新时间'];
+  const thead = `<thead>
+    <tr>
+      ${before.map((c) => `<th rowspan="2">${c}</th>`).join('')}
+      <th colspan="3" class="th-group">案例</th>
+      ${after.map((c) => `<th rowspan="2">${c}</th>`).join('')}
+    </tr>
+    <tr>${['输入', '输出', '效果'].map((c) => `<th class="th-sub">${c}</th>`).join('')}</tr>
+  </thead>`;
+  const cases = Array.isArray(tool['案例']) && tool['案例'].length ? tool['案例'] : [null];
+  const K = cases.length;
+  const rows = cases.map((cse, i) => {
+    const caseTds = `${_ttCaseTd(cse, '输入')}${_ttCaseTd(cse, '输出')}${_ttCaseTd(cse, '效果')}`;
+    if (i === 0) {
+      return `<tr>${before.map((c) => _ttTd(c, tool, K)).join('')}${caseTds}${after.map((c) => _ttTd(c, tool, K)).join('')}</tr>`;
+    }
+    return `<tr>${caseTds}</tr>`;
+  }).join('');
+  return `<div class="mw-ttwrap"><table class="mw-tt">${thead}<tbody>${rows}</tbody></table></div>`;
+}
+// 渲染后标记真正溢出的工具单元格(才显示蒙版 + 可点击)
+function markToolClamps() {
+  document.querySelectorAll('.mw-tt .tcell').forEach((el) => {
+    if (el.classList.contains('open')) return;
+    if (el.scrollHeight > el.clientHeight + 2) el.classList.add('clamped');
+    else el.classList.remove('clamped');
+  });
+}
+
 function renderCard(hit) {
   let titleHtml = esc(hit.title || '(无标题)');
   if (hit.highlight?.title?.[0]) titleHtml = hit.highlight.title[0];
@@ -1002,14 +1119,15 @@ function renderCard(hit) {
   const updatedAt = hit.updated_at
     ? new Date(hit.updated_at).toLocaleDateString('zh-CN') : '';
 
-  // 正文展开区:content 是一条工序解构(procedure),复用工序步骤表渲染;
-  // 解析失败或无步骤时回退为格式化 JSON。
+  // 正文展开区:content 可能是一条工序解构(procedure,含 steps)或一个工具解构(tool,含工具名称)。
+  // 工序 → 工序步骤表;工具 → 工具解构表;都解析不出时回退为格式化 JSON。
   let contentSection = '';
   if (hit.content) {
     let inner = '';
     try {
-      const proc = JSON.parse(hit.content);
-      if (proc && Array.isArray(proc.steps) && proc.steps.length) inner = renderSteps(proc.steps);
+      const obj = JSON.parse(hit.content);
+      if (obj && Array.isArray(obj.steps) && obj.steps.length) inner = renderSteps(obj.steps);
+      else if (obj && ('工具名称' in obj || '实质作用域' in obj || '创作层级' in obj)) inner = renderToolTable(obj);
     } catch {}
     if (!inner) {
       let display = hit.content;

+ 122 - 5
examples/mode_workflow/server.py

@@ -132,8 +132,9 @@ def _release_cases(mode, case_ids):
             _INFLIGHT.discard((mode, cid))
 
 
-def _spawn_task(kind, cmd, release=None):
-    """起子进程跑 pipeline。release=(mode, case_ids):任务结束时释放这些 case 的认领。"""
+def _spawn_task(kind, cmd, release=None, meta=None):
+    """起子进程跑 pipeline。release=(mode, case_ids):任务结束时释放这些 case 的认领。
+    meta:附加到任务记录的元信息(如搜索任务的 {query_id, query}),供进度接口枚举。"""
     LOG_DIR.mkdir(parents=True, exist_ok=True)
     task_id = f"{kind}_{datetime.now().strftime('%m%d%H%M%S%f')}"
     log_path = LOG_DIR / f"{task_id}.log"
@@ -141,7 +142,8 @@ def _spawn_task(kind, cmd, release=None):
     proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT,
                             cwd=str(HERE), text=True)
     with _TASK_LOCK:
-        TASKS[task_id] = {"proc": proc, "log": log_path, "status": "running"}
+        TASKS[task_id] = {"proc": proc, "log": log_path, "status": "running",
+                          **(meta or {})}
 
     def _wait():
         rc = proc.wait()
@@ -171,6 +173,30 @@ def _task_status(task_id):
     return {"status": t["status"], "log_tail": tail}
 
 
+def _search_progress(mode="process"):
+    """某方向搜索执行进度。两个口径互补:
+      - executed:**durable**,= 搜索表里 distinct query_id 数(已产出结果的 query),
+        重启不丢;有 1000 个词要搜时,进度 = executed / 1000(总数由调用方掌握)。
+      - session_tasks:**本进程**生命周期内发起的 search 任务统计(running/done/failed),
+        重启清零(任务记录仅存内存,见 TASKS 注释)。批量搜索时实时看「跑了几个」。"""
+    executed = db.count_executed_queries(mode)
+    with _TASK_LOCK:
+        tasks = [{"task_id": tid, "query_id": t.get("query_id"),
+                  "query": t.get("query"), "status": t["status"]}
+                 for tid, t in TASKS.items() if tid.startswith("search_")]
+    tasks.sort(key=lambda t: t["task_id"], reverse=True)   # 新任务在前
+    tally = {"total": len(tasks), "running": 0, "done": 0, "failed": 0}
+    for t in tasks:
+        if t["status"] in tally:
+            tally[t["status"]] += 1
+    return {
+        "executed": executed,
+        "session_tasks": tally,
+        "running_queries": [t["query_id"] for t in tasks if t["status"] == "running"],
+        "recent": tasks[:50],   # 最近 50 个搜索任务明细(task_id/query_id/query/status)
+    }
+
+
 _ALLOCATED_QIDS = set()       # 已分配但行可能尚未落库的 query_id(异步搜索:POST 即返回,行几分钟后才写)
 _QID_LOCK = threading.Lock()
 
@@ -445,7 +471,78 @@ class Handler(BaseHTTPRequestHandler):
         self.send_header("Content-Length", str(len(payload)))
         self.send_header("Cache-Control", "public, max-age=86400")
         self.end_headers()
-        self.wfile.write(payload)
+        try:
+            self.wfile.write(payload)
+        except (BrokenPipeError, ConnectionResetError):
+            pass  # 客户端中途取消(常见于 <img>/<video> 卸载),非错误,静默
+
+    # 视频帖的 videos[0] 多为需 Referer 的播放 API(抖音 aweme/play、视频号等),
+    # 浏览器 <video> 直连给不了 Referer → 慢/403。图片代理 _proxy_image 又是整文件
+    # 缓冲、无 Range,拿来喂视频会让 <video> 无法边下边播+拖拽,且客户端中断即 BrokenPipe。
+    # 本代理:按 host 注入 Referer + 透传 Range 头 + 分块流式回传(支持 206 断点/拖拽)。
+    _VIDEO_REFERERS = {
+        "douyin.com": "https://www.douyin.com/",
+        "weixin.qq.com": "https://channels.weixin.qq.com/",
+        "xiaohongshu.com": "https://www.xiaohongshu.com/",
+        "xhscdn.com": "https://www.xiaohongshu.com/",
+        "bilibili.com": "https://www.bilibili.com/",
+        "bilivideo.c": "https://www.bilibili.com/",
+        "weibo.c": "https://weibo.com/",
+    }
+
+    def _proxy_video(self, url):
+        if not url or not (url.startswith("http://") or url.startswith("https://")):
+            return self._err("非法视频地址", 400)
+        host = (urlparse(url).hostname or "").lower()
+        if host in ("localhost", "127.0.0.1", "0.0.0.0", "::1") or \
+           host.startswith("10.") or host.startswith("192.168.") or \
+           host.startswith("169.254.") or host.endswith(".internal"):
+            return self._err("禁止的视频地址", 403)
+        headers = {
+            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                          "AppleWebKit/537.36 (KHTML, like Gecko) "
+                          "Chrome/120.0 Safari/537.36",
+            "Accept": "*/*",
+        }
+        for key, ref in self._VIDEO_REFERERS.items():
+            if key in host:
+                headers["Referer"] = ref
+                break
+        rng = self.headers.get("Range")
+        if rng:
+            headers["Range"] = rng
+        req = urllib.request.Request(url, headers=headers)
+        try:
+            resp = urllib.request.urlopen(req, timeout=60)
+        except urllib.error.HTTPError as e:
+            return self._err(f"上游视频返回 {e.code}", e.code if 400 <= e.code < 600 else 502)
+        except Exception as e:
+            return self._err(f"视频不可达:{type(e).__name__}: {e}", 502)
+        # 头已发出后若中断,不能再走 _err(会二次写头),只能静默吞异常
+        try:
+            self.send_response(getattr(resp, "status", 200) or 200)
+            self.send_header("Content-Type", resp.headers.get("Content-Type", "video/mp4"))
+            for h in ("Content-Length", "Content-Range"):
+                v = resp.headers.get(h)
+                if v:
+                    self.send_header(h, v)
+            self.send_header("Accept-Ranges", resp.headers.get("Accept-Ranges", "bytes"))
+            self.send_header("Cache-Control", "public, max-age=3600")
+            self.end_headers()
+            while True:
+                chunk = resp.read(65536)
+                if not chunk:
+                    break
+                self.wfile.write(chunk)
+        except (BrokenPipeError, ConnectionResetError):
+            pass  # 客户端拖进度/关播放器即中断,媒体流常态,静默
+        except Exception:
+            pass
+        finally:
+            try:
+                resp.close()
+            except Exception:
+                pass
 
     def _proxy_knowledge(self, body=None):
         """把 /api/v1/knowledge* 同源反代到 KNOWLEDGE_API_BASE(明文后端)。
@@ -517,6 +614,22 @@ class Handler(BaseHTTPRequestHandler):
                 self._json_etag(_queries_cached(qs.get("mode", "process")))
             elif u.path == "/api/posts":
                 self._json(db.fetch_posts(qs.get("query_id", ""), qs.get("mode", "process")))
+            elif u.path == "/api/all_posts":
+                # 某方向「全部帖子」:跨所有 query 的列表,分页 + 可选 采纳过滤/按帖去重
+                try:
+                    page = max(1, int(qs.get("page", "1")))
+                    page_size = min(500, max(1, int(qs.get("page_size", "100"))))
+                except ValueError:
+                    return self._err("page/page_size 须为整数", 400)
+                total, posts = db.fetch_all_posts(
+                    qs.get("mode", "process"),
+                    adopted_only=qs.get("adopted") in ("1", "true"),
+                    distinct=qs.get("distinct") in ("1", "true"),
+                    limit=page_size, offset=(page - 1) * page_size)
+                self._json({"total": total, "page": page,
+                            "page_size": page_size, "posts": posts})
+            elif u.path == "/api/search_progress":
+                self._json(_search_progress(qs.get("mode", "process")))
             elif u.path == "/api/post":
                 # 单帖详情(正文/配图/评估全量):列表已瘦身,详情按需取;带 ETag/304
                 r = db.fetch_post(qs.get("query_id", ""), qs.get("case_id", ""),
@@ -548,6 +661,8 @@ class Handler(BaseHTTPRequestHandler):
                 self._json(r) if r else self._err("未知 task_id", 404)
             elif u.path == "/api/img":
                 self._proxy_image(qs.get("u", ""))
+            elif u.path == "/api/video":
+                self._proxy_video(qs.get("u", ""))
             elif u.path.startswith("/icons/") and u.path.endswith(".svg"):
                 # 渠道 logo 静态 SVG;按 basename 取文件,杜绝路径穿越
                 name = Path(u.path).name
@@ -658,7 +773,9 @@ class Handler(BaseHTTPRequestHandler):
                     cmd += ["--platforms", payload["platforms"]]
                 if payload.get("max_count"):
                     cmd += ["--max-count", str(payload["max_count"])]
-                self._json({"task_id": _spawn_task("search", cmd), "query_id": qid})
+                self._json({"task_id": _spawn_task("search", cmd,
+                                                   meta={"query_id": qid, "query": query}),
+                            "query_id": qid})
             else:
                 self._err("not found", 404)
         except Exception as e:

+ 350 - 0
examples/mode_workflow/工序接口文档.md

@@ -0,0 +1,350 @@
+# mode_workflow · 工序(Workflow)接口文档
+
+> 来源:`examples/mode_workflow`(`server.py` + `db.py`)。本文档只整理**工序方向**(`mode=process`)相关接口。
+> 工具方向(`mode=tools`)多数接口同形,仅把 `process→tools`、`mode_process→mode_tools`、`search_process→search_tools` 替换即可,文末附差异说明。
+
+## 0. 基础约定
+
+- **Base URL**:`http://<host>:8772`(`server.py` 的 `PORT=8772`)。
+- **数据源**:MySQL 四表为唯一事实源,工序方向用其中两张:
+  - `search_process`:每行 = 一个 `(query, 帖子)`,搜索 + LLM 评估结果。
+  - `mode_process`:每行 = 一个解构出的**工序**(一帖可解出多个工序;`steps` 等嵌套结构存 JSON 列)。
+- **方向参数**:凡带 `mode` 的接口,工序方向传 `mode=process`(缺省即 `process`)。
+- **响应格式**:统一 `application/json; charset=utf-8`。出错返回 `{"error": "<msg>"}` + 对应 HTTP 状态码(400/404/500)。
+- **缓存**:部分 GET 带 `ETag`,命中返回 `304`;`/api/dashboard`、`/api/queries` 另有 60s 内存缓存(解构任务完成时作废)。
+- **解构是异步的**:发起解构/搜索/评分的 POST 立即返回 `task_id`,真正的计算在 `server.py` 起的子进程里跑;用 `GET /api/task_status` 轮询。
+- **版本**:工序解构按版本号 `v_MMDDHHMM` 保留历史;同版本重跑幂等覆盖,跨版本共存。`link_*` 前缀是**跨 query 复制版**(同一帖被多个 query 搜到时只真实解构一次,其余用 `link_` 复制,`cost=0`),“最新版本”默认排除 `link_*`。
+
+---
+
+## 1. 数据看板
+
+### GET /api/dashboard
+工序 + 工具方向的全量聚合(覆盖度 / 成本 / 进度)。带 ETag。
+
+**响应**
+```jsonc
+{
+  "result": {
+    "collected_by_platform": [["xhs", 120], ["gzh", 80]], // 采集帖子数 by 平台 [name,count]
+    "extracted_by_platform": [["xhs", 60], ["gzh", 30]],  // 已解构帖子数 by 平台
+    "matrix_covered": 42,      // 内容树命中的有效节点数(steps 的 action叶 × 输入/输出 type ∩ tier≥1)
+    "matrix_valid": 643,       // 内容树有效(tier≥1)节点总数
+    "matrix_cells": [[3, 12], [5, 7]],     // 命中的 [action_index, type_index] 列表
+    "matrix_actions": ["获取", "提取", "生成", ...],  // 动作维度名(行)
+    "matrix_types": ["指令", "参数", ...],            // 类型维度名(列)
+    "substance_count": 18, "substance_top": [["产品外观", 9], ...], // 实质维度覆盖
+    "form_count": 12,        "form_top": [["纪实记录", 5], ...],     // 形式维度覆盖
+    "post_count": 200,             // 采集帖子总数
+    "extracted_post_count": 90,    // 已解构帖子总数
+    "tool_count": 25,              // 去重工具数(工具方向)
+    "via_top10": [["nano_banana", 30], ["klingai", 12]]  // steps[].via 工具使用 Top10
+  },
+  "process_data": {
+    "run_count": 90,           // 解构调用次数(按 case+version 去重)
+    "avg_cost": 0.0512, "total_cost": 4.61,   // 成本(USD)
+    "avg_duration": 14.3, "total_duration": 1287.0,  // 耗时(秒)
+    "cost_trend": [["06-17", 1.2], ["06-18", 0.9]],  // 每日成本趋势
+    "process_progress": {"done": 60, "total": 80},   // 工序解构进度(分母=采纳帖)
+    "tools_progress":   {"done": 30, "total": 50}    // 工具解构进度
+  }
+}
+```
+
+---
+
+## 2. Query 与帖子
+
+### GET /api/queries?mode=process
+某方向搜索表派生的 query 列表(含工序解构进度)。带 ETag。
+
+**响应**:`Array<Query>`
+```jsonc
+[{
+  "query_id": "q0020",
+  "query_text": "人物 姿势 精准控制 怎么做",
+  "post_count": 20,        // 该 query 搜到的帖子数
+  "hit_count": 8,          // 达到“采纳”口径的帖子数
+  "process_done": 5,       // 已工序解构的帖子数(distinct case)
+  "tools_done": 2          // 已工具解构的帖子数
+}]
+```
+
+### GET /api/posts?query_id=q0020&mode=process
+某 query 下的帖子列表(瘦身列,不含正文/评估大字段)。
+
+**Query 参数**:`query_id`(必填)、`mode`(缺省 `process`)。
+
+**响应**:`Array<PostListItem>`
+```jsonc
+[{
+  "id": 123, "query_id": "q0020", "query_text": "...",
+  "case_id": "xhs_abc123",       // 帖子物理身份 = platform_channelContentId
+  "platform": "xhs", "channel_content_id": "abc123",
+  "title": "...", "url": "https://...", "content_type": "normal",
+  "images": ["https://..."], "like_count": 1024, "publish_time": "2026-06-12",
+  "quality_score": 8.6, "quality_grade": "A",
+  "found_by": ["人物 姿势 精准控制"],   // 命中的措辞
+  "knowledge_type": ["工序", "能力"],   // 评估判定的知识类型子集
+  "overall_score": 8.2,                // (相关均值+质量均值)/2
+  "adopted": true,                     // 是否“采纳”(is_adopted_rel 口径)
+  "has_process": true,                 // 该帖是否已有工序解构
+  "has_tools": false                   // 该帖是否已有工具解构
+}]
+```
+
+### GET /api/all_posts?mode=process
+某方向**全部帖子**(跨所有 query),分页返回。瘦身列同 `/api/posts`(不含正文/评估大字段)。
+与 `/api/posts` 的区别:后者限定单个 `query_id`,本接口取该方向全表。
+
+**Query 参数**
+| 参数 | 必填 | 缺省 | 说明 |
+|---|---|---|---|
+| `mode` | 否 | `process` | 方向(`process` / `tools`) |
+| `page` | 否 | `1` | 页码(从 1 起) |
+| `page_size` | 否 | `100` | 每页条数(上限 500) |
+| `adopted` | 否 | `0` | `1`/`true` 只返回采纳帖(`is_adopted_rel` 口径) |
+| `distinct` | 否 | `0` | `1`/`true` 按 `case_id` 去重(同帖被多 query 搜到时只保留 `overall_score` 最高的一行) |
+
+> `page`/`page_size` 非整数返回 `400 {"error":"page/page_size 须为整数"}`。
+
+**响应**
+```jsonc
+{
+  "total": 1197,        // 过滤(+去重)后的总条数(用于算总页数)
+  "page": 1,
+  "page_size": 100,
+  "posts": [ /* Array<PostListItem>,字段同 /api/posts(含 adopted/has_process/has_tools) */ ]
+}
+```
+> 默认(不去重)`total` = 该方向 `(query, 帖子)` 行总数;同一帖被 N 个 query 搜到即计 N 行。
+> 需“物理帖子数”时传 `distinct=1`。`has_process`/`has_tools` 为**跨 query 全局**判定(该帖在 `mode_process`/`mode_tools` 中是否有解构)。
+
+### GET /api/post?query_id=q0020&case_id=xhs_abc123&mode=process
+单帖完整详情(含正文 `body`、`videos`、`llm_evaluation` 全量)。带 ETag;无帖返回 `404 {"error":"无此帖"}`。
+
+**响应**:`search_process` 的整行(在 `PostListItem` 基础上额外含)
+```jsonc
+{
+  "...": "(含 PostListItem 全部列)",
+  "body": "帖子正文全文",
+  "videos": ["https://..."],
+  "llm_evaluation": { /* 评估全量 blob */ }
+}
+```
+
+---
+
+## 3. 工序解构结果
+
+### GET /api/extract?mode=process&case_id=xhs_abc123&version=
+**一次取**版本列表 + 指定版本的工序解构详情(前端少一次往返)。`version` 省略取最新版。
+
+**响应**
+```jsonc
+{
+  "versions": [          // 该帖的所有解构版本(link_* 排在最后)
+    {"version": "v_06181530", "n": 3, "model": "anthropic/claude-sonnet-4-6"}
+  ],
+  "data": { /* 见下 ProcessPayload;无解构记录时为 null */ },
+  "missing": false       // data 为 null 时 true
+}
+```
+
+### GET /api/process_versions?case_id=xhs_abc123
+仅版本列表。**响应**:`Array<{version, n, model}>`(同上 `versions`)。
+
+### GET /api/process?case_id=xhs_abc123&version=
+单帖工序解构结果(`version` 省略取最新真实版)。无记录返回 `404 {"error":"无解构记录"}`。
+
+**响应**:`ProcessPayload`
+```jsonc
+{
+  "case_id": "xhs_abc123",
+  "version": "v_06181530",
+  "platform": "xhs",
+  "title": "帖子标题",
+  "model": "anthropic/claude-sonnet-4-6",
+  "cost_usd": 0.0512,
+  "duration_s": 14.3,
+  "source": { /* 解构返回的 source 块:帖子来源信息 */ },
+  "procedures": [          // 一帖可含多个工序
+    {
+      "id": "p1",
+      "name": "AI商品主图生成",
+      "purpose": "生成电商可用的高质量商品主图",
+      "category": "产物创造",          // 产物创造/资产建设/自动化/分析/学习
+      "declarations": { /* 工序级声明:inputs/outputs 等 */ },
+      "type_registry": { /* 类型注册表 */ },
+      "tools_used": ["nano_banana", "klingai"],  // 从 steps[].via 去重
+      "steps": [                       // 步骤数组(核心结构,见下)
+        {
+          "id": "s1",
+          "kind": "block",             // block / nested
+          "group": "s5",               // nested 时所属父步
+          "via": "nano_banana",        // 外部工具;human/- 表示无工具
+          "effect": "主体生成",
+          "action": "生成/元素生成",    // 动作(叶子用于覆盖度统计)
+          "inputs":  [{"type": "提示词", "name": "风格提示词", "value": "...", "anchor": "← 工序输入"}],
+          "outputs": [{"id": "s1o1", "type": "成品图", "value": "<画面描述>", "anchor": "→ 交付"}]
+        }
+      ]
+    }
+  ]
+}
+```
+
+> `steps[].action` 的叶子(按 `/` 取最后一段)× `inputs/outputs[].type` 用于 Dashboard 内容树覆盖度命中统计。
+
+### GET /api/extract_prompt?mode=process
+取当前工序解构 system prompt 原文(供“重新解构·编辑 Prompt”弹框预填)。
+
+**响应**:`{"prompt": "<prompts/procedure_extract_system.md 全文>"}`
+
+---
+
+## 4. 发起工序解构(异步)
+
+### POST /api/extract_process
+对指定 query 下的若干帖子发起**工序解构**,起子进程 `stages/procedure_extract.py`。
+
+**请求体**
+```jsonc
+{
+  "query_id": "q0020",            // 必填
+  "case_ids": ["xhs_abc", "gzh_def"], // 必填,待解构帖子的 case_id 列表
+  "model": "anthropic/claude-sonnet-4-6", // 选填,覆盖默认模型
+  "prompt": "<临时 system prompt>", // 选填,仅本次生效,不改 prompts/*.md
+  "force": false                  // 选填,true 跳过“按 case 全局去重”,强制重解构
+}
+```
+
+**行为 / 去重**
+- 默认按 `case_id` **全局去重**:同一帖跨 query 只真实解构一次,已解构过的用 `db.link_process` 复制关联(`cost=0`)。换 prompt/模型要对比时传 `force:true`。
+- **认领锁**:剔除正在解构中的 `case`,防并发重复解构(白花 LLM 钱)。
+
+**响应**
+```jsonc
+{
+  "task_id": "proc_xxx",           // 轮询用;全部被跳过时为 null
+  "skipped": ["gzh_def"],          // 因正在解构中被跳过的 case
+  "note": "..."                    // 仅当全部跳过时出现
+}
+```
+
+---
+
+## 5. 发起搜索(新增 query · 工序方向)
+
+### POST /api/run_search
+跑一次搜索 + 评估,起子进程 `stages/search_eval.py`。新 `query_id` 即等于“新增一个 query”。
+
+**请求体**
+```jsonc
+{
+  "query": "人物 姿势 精准控制 怎么做",  // 必填,原始组合词(存入 query 列表)
+  "query_id": "q0021",          // 选填,缺省服务端 _next_query_id() 自动分配
+  "query_text": "...",          // 选填,实际搜索用的改写词
+  "synonyms": "...",            // 选填,近义词
+  "mode_type": "工序",          // 选填,"工序" / "工具";落表方向(工序→search_process)
+  "platforms": "xhs,gzh",       // 选填,渠道;默认 xhs,gzh 各 20
+  "max_count": 20               // 选填,每渠道条数
+}
+```
+> 注:评估会按帖子的知识类型标签自动路由落表(工序/能力→`search_process`,工具→`search_tools`,两者都含则写两表),`mode_type` 是搜索方向倾向。
+
+**响应**:`{"task_id": "search_xxx", "query_id": "q0021"}`
+
+---
+
+## 6. 任务状态轮询
+
+### GET /api/task_status?task_id=proc_xxx
+轮询异步任务(解构 / 搜索 / 评分)状态。未知 `task_id` 返回 `404 {"error":"未知 task_id"}`。
+
+**响应**
+```jsonc
+{
+  "status": "running",   // running / done / failed(见 server 任务管理)
+  "log_tail": "...最后 3000 字符日志..."
+}
+```
+
+### GET /api/search_progress?mode=process
+搜索**执行进度**:回答“1000 个 query 词要搜,已经执行了多少”。两个口径互补——
+- `executed`:**durable**,= 搜索表里 distinct `query_id` 数(已产出结果的 query),服务重启不丢。
+  有 N 个词要搜时,进度 ≈ `executed / N`(分母 N 由调用方掌握,服务端不持久化“计划清单”)。
+- `session_tasks`:**本进程**生命周期内发起的 search 任务统计,重启清零(任务记录仅存内存,见 `server.py:TASKS`)。批量连搜时用来实时看“正在跑/已跑完几个”。
+
+**Query 参数**:`mode`(缺省 `process`)。
+
+**响应**
+```jsonc
+{
+  "executed": 29,              // 已执行(已产出结果)的 distinct query 数 —— durable
+  "session_tasks": {           // 本进程内 search 任务计数(重启清零)
+    "total": 5, "running": 1, "done": 3, "failed": 1
+  },
+  "running_queries": ["q0042"],     // 正在搜索中的 query_id
+  "recent": [                        // 最近 ≤50 个 search 任务明细(新任务在前)
+    {"task_id": "search_0624...", "query_id": "q0042", "query": "人物 姿势 怎么做", "status": "running"}
+  ]
+}
+```
+> **口径说明**:`executed` 统计的是“已写入结果的 query”,一次搜索若 **0 命中**不写行、不计入。
+> 若需精确的“计划 1000 → 已发起 X”进度,按 `session_tasks`(同一进程内连续 `POST /api/run_search`)统计;
+> 跨重启的精确批次追踪需另建“搜索计划台账”(当前未实现,按需扩展)。
+
+---
+
+## 7. Query 规则组织器(铺词 → 评分 → 搜索)
+
+用内容树维度系统化铺 query → Sonnet 评分 → 高亮达标 → 一键搜。生成的工序方向 query 走第 5 节落库。
+
+### GET /api/query_matrix
+正交表底图:`动作 × 类型` 整张矩阵(来自 `reference/judged_matrix.json`)。带 ETag。
+
+### GET /api/category_tree?source_type=实质
+分类树(`实质` / `形式`),用于维度 chips 逐级下钻(仅作 Sonnet 领域上下文)。带 ETag。
+
+### POST /api/query_score
+对当前维度上下文下的 tier≥1 格子用 Sonnet 打分(natural/findable/useful + keep + rewrite + reason)。
+
+**请求体**
+```jsonc
+{
+  "tool_type": "AI", "modality": "图片", "suffix": "怎么做",
+  "substance_path": ["表象", "视觉"], "form_path": ["呈现", "视觉"],
+  "model": "anthropic/claude-sonnet-4-6",
+  "force": false                 // true 跳过缓存重评
+}
+```
+**响应**:`{"sel": "<16位hex>", "task_id": "score_xxx", "cached": false}`(命中缓存时 `cached:true` 且无 `task_id`)。
+
+### GET /api/query_score?sel=<16位hex>
+取某次评分结果(按选择哈希缓存于 `.cache/query_score/<sel>.json`)。`sel` 必须是 16 位十六进制,否则 `400`;结果未就绪返回 `202 {"pending": true}`。
+
+---
+
+## 8. 工序知识导入(反代知识库后端)
+
+### `* /api/v1/knowledge*`
+同源反代到 `.env` 的 `KNOWLEDGE_API_BASE`(明文后端),GET/POST 原样透传,本服务不解析 JSON。
+工序解构采纳后由 `stages/import_process_knowledge.py` 调此接口导入知识库;`knowledge_ingest_log` 台账按 `(case_id, proc_index)` 防重复上传,导入时记录 `mode_process` 版本,版本变了应重导。
+
+---
+
+## 附:工具方向(`mode=tools`)差异
+
+| 工序方向 | 工具方向 |
+|---|---|
+| `GET /api/posts?mode=process` | `GET /api/posts?mode=tools` |
+| `GET /api/all_posts?mode=process` | `GET /api/all_posts?mode=tools` |
+| `GET /api/search_progress?mode=process` | `GET /api/search_progress?mode=tools` |
+| `GET /api/extract?mode=process` | `GET /api/extract?mode=tools` |
+| `GET /api/process_versions` `GET /api/process` | `GET /api/tools_versions` `GET /api/tools` |
+| `POST /api/extract_process` | `POST /api/extract_tools` |
+| 表 `search_process` / `mode_process` | 表 `search_tools` / `mode_tools` |
+| 台账 `knowledge_ingest_log` | 台账 `tools_ingest_log` |
+
+工具方向 payload 字段不同(`tool_name` / `substance_scope` / `form_scope` / `creation_layer` / `cases_json` / `defects_json` 等),不在本工序文档展开。