Prechádzať zdrojové kódy

feat(fixed_query_eval): 新增多版本工具解构与公网分享功能

- 将工具解构prompt外置至单独文件,便于独立迭代维护
- 实现工具解构多版本共存,本地与数据库均保留历史版本
- 新增share.sh一键脚本,支持快速启动服务与cloudflared公网隧道
- 优化前端工具展示:支持卡片/表格视图切换,增强图片灯箱翻页功能
- 更新后端接口与数据库逻辑,支持按版本查询与存储工具数据
- 更新.gitignore与claude配置,适配项目新文件与脚本
刘文武 12 hodín pred
rodič
commit
bc751d506b

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

@@ -51,7 +51,16 @@
       "Bash(echo \"cloudflared PID: $!\")",
       "Bash(echo \"cloudflared PID: $!\")",
       "Bash(xargs kill -9)",
       "Bash(xargs kill -9)",
       "Bash(awk '{print $9, $5}')",
       "Bash(awk '{print $9, $5}')",
-      "Bash(awk '{printf \"%-18s %s bytes\\\\n\", $9, $5}')"
+      "Bash(awk '{printf \"%-18s %s bytes\\\\n\", $9, $5}')",
+      "Bash(chmod +x share.sh)",
+      "Bash(bash -n share.sh)",
+      "Bash(./share.sh status *)",
+      "Bash(./share.sh url *)",
+      "Bash(pkill -f \"cloudflared tunnel\")",
+      "Bash(lsof -nP -tiTCP:8770 -sTCP:LISTEN)",
+      "Bash(xargs kill)",
+      "Bash(rm -f /tmp/cf_tunnel.log /tmp/server8770.log)",
+      "Bash(./share.sh start *)"
     ],
     ],
     "deny": [],
     "deny": [],
     "ask": []
     "ask": []

+ 2 - 1
.gitignore

@@ -112,4 +112,5 @@ HOW_IT_RUNS.md
 PROJECT_STRUCTURE.md
 PROJECT_STRUCTURE.md
 runs_full/
 runs_full/
 .ocr_cache
 .ocr_cache
-fixed_query_eval/docs/
+fixed_query_eval/docs/
+fixed_query_eval/runs_full/

+ 47 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/.cloudflared.log

@@ -0,0 +1,47 @@
+2026-06-11T02:33:40Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
+2026-06-11T02:33:40Z INF Requesting new quick Tunnel on trycloudflare.com...
+2026-06-11T02:33:44Z INF +--------------------------------------------------------------------------------------------+
+2026-06-11T02:33:44Z INF |  Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
+2026-06-11T02:33:44Z INF |  https://colours-float-distributor-went.trycloudflare.com                                  |
+2026-06-11T02:33:44Z INF +--------------------------------------------------------------------------------------------+
+2026-06-11T02:33:44Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
+2026-06-11T02:33:44Z INF Version 2026.6.0 (Checksum a13041d6ffff9d56f7df1629c24d9c0bacf4f73409d6cab0f5a1f60631ba9667)
+2026-06-11T02:33:44Z INF GOOS: darwin, GOVersion: go1.26.4, GoArch: amd64
+2026-06-11T02:33:44Z INF Settings: map[ha-connections:1 p:http2 protocol:http2 url:http://localhost:8770]
+2026-06-11T02:33:44Z INF cloudflared will not automatically update if installed by a package manager.
+2026-06-11T02:33:44Z INF Generated Connector ID: eadaf12f-1007-43fe-93f2-279140b8773d
+2026-06-11T02:33:44Z INF Initial protocol http2
+2026-06-11T02:33:44Z INF ICMP proxy will use 192.168.81.50 as source for IPv4
+2026-06-11T02:33:44Z INF ICMP proxy will use ::1 in zone lo0 as source for IPv6
+2026-06-11T02:33:44Z INF Created ICMP proxy listening on 192.168.81.50:0
+2026-06-11T02:33:44Z INF Created ICMP proxy listening on [::1]:0
+2026-06-11T02:33:44Z INF ICMP proxy will use 192.168.81.50 as source for IPv4
+2026-06-11T02:33:44Z INF ICMP proxy will use ::1 in zone lo0 as source for IPv6
+2026-06-11T02:33:44Z INF Starting metrics server on 127.0.0.1:20241/metrics
+2026-06-11T02:33:44Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveID(65074) CurveP256] connIndex=0 event=0 ip=198.41.192.77
+2026-06-11T02:33:46Z INF Registered tunnel connection connIndex=0 connection=6bb57297-9ab0-4bf7-9f72-6aeb0bc233dc event=0 ip=198.41.192.77 location=lax10 protocol=http2
+2026-06-11T02:33:54Z INF +-----------------------------------------------------------------------------------------------+
+2026-06-11T02:33:54Z INF |                                    CONNECTIVITY PRE-CHECKS                                    |
+2026-06-11T02:33:54Z INF +-----------------------------------------------------------------------------------------------+
+2026-06-11T02:33:54Z INF |  COMPONENT         TARGET                     STATUS  DETAILS                                 |
+2026-06-11T02:33:54Z INF |  DNS Resolution    region1.v2.argotunnel.com  PASS    DNS Resolved successfully               |
+2026-06-11T02:33:54Z INF |  DNS Resolution    region2.v2.argotunnel.com  PASS    DNS Resolved successfully               |
+2026-06-11T02:33:54Z INF |  UDP Connectivity  region1.v2.argotunnel.com  PASS    QUIC connection successful              |
+2026-06-11T02:33:54Z INF |  UDP Connectivity  region2.v2.argotunnel.com  FAIL    QUIC connection failed                  |
+2026-06-11T02:33:54Z INF |  TCP Connectivity  region1.v2.argotunnel.com  PASS    HTTP/2 connection successful            |
+2026-06-11T02:33:54Z INF |  TCP Connectivity  region2.v2.argotunnel.com  PASS    HTTP/2 connection successful            |
+2026-06-11T02:33:54Z INF |  Cloudflare API    api.cloudflare.com:443     PASS    API is reachable                        |
+2026-06-11T02:33:54Z INF |  WARNING: Allow outbound QUIC traffic on port 7844 or use HTTP2.                              |
+2026-06-11T02:33:54Z INF |                                                                                               |
+2026-06-11T02:33:54Z INF |  SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'http2'.  |
+2026-06-11T02:33:54Z INF +-----------------------------------------------------------------------------------------------+
+2026-06-11T02:33:54Z INF precheck component="DNS Resolution" details="DNS Resolved successfully" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=pass target=region1.v2.argotunnel.com
+2026-06-11T02:33:54Z INF precheck component="DNS Resolution" details="DNS Resolved successfully" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=pass target=region2.v2.argotunnel.com
+2026-06-11T02:33:54Z INF precheck component="UDP Connectivity" details="QUIC connection successful" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=pass target=region1.v2.argotunnel.com
+2026-06-11T02:33:54Z INF precheck component="UDP Connectivity" details="QUIC connection failed" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=fail target=region2.v2.argotunnel.com
+2026-06-11T02:33:54Z INF precheck component="TCP Connectivity" details="HTTP/2 connection successful" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=pass target=region1.v2.argotunnel.com
+2026-06-11T02:33:54Z INF precheck component="TCP Connectivity" details="HTTP/2 connection successful" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=pass target=region2.v2.argotunnel.com
+2026-06-11T02:33:54Z INF precheck component="Cloudflare API" details="API is reachable" run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a status=pass target=api.cloudflare.com:443
+2026-06-11T02:33:54Z INF precheck complete hard_fail=false run_id=284bf4de-59c1-41e5-9be3-bbcb31374a0a suggested_protocol=http2
+2026-06-11T02:55:26Z ERR  error="Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp 127.0.0.1:8770: connect: connection refused" connIndex=0 event=1 ingressRule=0 originService=http://localhost:8770
+2026-06-11T02:55:26Z ERR failed to serve incoming request error="Failed to proxy HTTP: Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp 127.0.0.1:8770: connect: connection refused"

+ 1 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/.cloudflared.pid

@@ -0,0 +1 @@
+30858

+ 0 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/.server.log


+ 1 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/.server.pid

@@ -0,0 +1 @@
+30849

+ 5 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/README.md

@@ -108,7 +108,12 @@ python server.py                           # http://0.0.0.0:8770
 
 
 **解构结果字段**(每个工具):工具名称 / 实质作用域 / 形式作用域 / 创作层级(制作层·创作层)/ 来源链接 / 输入 / 输出 / 用法 / 案例 / 缺点 / 最新更新时间。null 字段在 UI 自动省略。
 **解构结果字段**(每个工具):工具名称 / 实质作用域 / 形式作用域 / 创作层级(制作层·创作层)/ 来源链接 / 输入 / 输出 / 用法 / 案例 / 缺点 / 最新更新时间。null 字段在 UI 自动省略。
 
 
+**版本(多版本共存)**:每次解构生成一个版本号 `v_月日时分`(如 `v_06102158`),**保留历史不覆盖**。详情页工具头部有「版本下拉」可切回历史版本对比(改了 prompt 后重新解构,能看不同版本)。默认显示最新版本。
+
+**展示**:工具头部「卡片视图 / 表格视图」切换;表格列对应 `fqe_tools` 字段。`案例` 字段为结构化对象数组 `[{输入,输出,效果}]`,卡片/表格均按结构渲染。
+
 **实现要点**:
 **实现要点**:
+- **Prompt 外置**:解构 prompt 在 `prompts/tool_extract_system.md`(不塞代码里,便于单独迭代);`tool_extract.py` 启动时读取。
 - `tool_extract.py` 单次多模态 LLM 调用(正文 + 配图),比工序解构(多轮 agent)轻得多;复用引擎的收图 / 帖子格式化 / JSON 重试封装。
 - `tool_extract.py` 单次多模态 LLM 调用(正文 + 配图),比工序解构(多轮 agent)轻得多;复用引擎的收图 / 帖子格式化 / JSON 重试封装。
 - 模型固定 `google/gemini-3.1-flash-lite`(`build_eval_llm_call("gemini-flash-lite")`,OpenRouter 后端);评分筛选用的也是 OpenRouter,与之一致。
 - 模型固定 `google/gemini-3.1-flash-lite`(`build_eval_llm_call("gemini-flash-lite")`,OpenRouter 后端);评分筛选用的也是 OpenRouter,与之一致。
 - 结果存 `runs_full/{q}/tools/{case_id}.json`,**已解构默认跳过**(详情页「重新解构」可强制覆盖)。
 - 结果存 `runs_full/{q}/tools/{case_id}.json`,**已解构默认跳过**(详情页「重新解构」可强制覆盖)。

+ 41 - 11
examples/process_pipeline/script/search_eval/fixed_query_eval/db.py

@@ -94,8 +94,10 @@ CREATE TABLE IF NOT EXISTS fqe_tools (
   defects_json  JSON          NULL     COMMENT '缺点数组',
   defects_json  JSON          NULL     COMMENT '缺点数组',
   updated_time  VARCHAR(64)   NULL     COMMENT '工具最新更新时间',
   updated_time  VARCHAR(64)   NULL     COMMENT '工具最新更新时间',
   model         VARCHAR(64)   NULL     COMMENT '解构模型',
   model         VARCHAR(64)   NULL     COMMENT '解构模型',
+  version       VARCHAR(16)   NULL     COMMENT '解构版本号 v_MMDDHHMM(每次解构生成;保留历史,多版本共存)',
   created_at    TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
   created_at    TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
   KEY idx_q_case (q, case_id),
   KEY idx_q_case (q, case_id),
+  KEY idx_q_case_ver (q, case_id, version),
   KEY idx_tool_name (tool_name)
   KEY idx_tool_name (tool_name)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具解构结果(每行一个工具)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具解构结果(每行一个工具)';
 """
 """
@@ -180,15 +182,17 @@ def upsert_posts(q, query_text, results):
         return 0
         return 0
 
 
 
 
-def upsert_tools(q, case_id, model, tools, platform=None, post_title=None):
-    """把一帖的工具解构结果写入 fqe_tools(先删该 (q,case_id) 旧行再插)。失败返回 0。"""
+def upsert_tools(q, case_id, model, tools, version, platform=None, post_title=None):
+    """写入一帖某个版本的工具解构结果。**保留历史**:不删其它版本,只删本 (q,case_id,version)
+    的旧行再插(保证同一版本重跑幂等)。失败返回 0。"""
     if not _enabled():
     if not _enabled():
         return 0
         return 0
     try:
     try:
         conn = _conn()
         conn = _conn()
         try:
         try:
             with conn.cursor() as cur:
             with conn.cursor() as cur:
-                cur.execute("DELETE FROM fqe_tools WHERE q=%s AND case_id=%s", (q, case_id))
+                cur.execute("DELETE FROM fqe_tools WHERE q=%s AND case_id=%s AND version=%s",
+                            (q, case_id, version))
                 if tools:
                 if tools:
                     rows = [(
                     rows = [(
                         q, case_id, platform, (post_title or "")[:500],
                         q, case_id, platform, (post_title or "")[:500],
@@ -197,14 +201,14 @@ def upsert_tools(q, case_id, model, tools, platform=None, post_title=None):
                         json.dumps(t.get("用法"), ensure_ascii=False) if t.get("用法") is not None else None,
                         json.dumps(t.get("用法"), ensure_ascii=False) if t.get("用法") is not None else None,
                         json.dumps(t.get("案例"), ensure_ascii=False) if t.get("案例") is not None else None,
                         json.dumps(t.get("案例"), ensure_ascii=False) if t.get("案例") is not None else None,
                         json.dumps(t.get("缺点"), ensure_ascii=False) if t.get("缺点") is not None else None,
                         json.dumps(t.get("缺点"), ensure_ascii=False) if t.get("缺点") is not None else None,
-                        t.get("最新更新时间"), model,
+                        t.get("最新更新时间"), model, version,
                     ) for t in tools]
                     ) for t in tools]
                     cur.executemany("""
                     cur.executemany("""
                     INSERT INTO fqe_tools
                     INSERT INTO fqe_tools
                       (q, case_id, platform, post_title, tool_name, substance_scope, form_scope,
                       (q, case_id, platform, post_title, tool_name, substance_scope, form_scope,
                        creation_layer, source_link, input_desc, output_desc,
                        creation_layer, source_link, input_desc, output_desc,
-                       usage_json, cases_json, defects_json, updated_time, model)
-                    VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+                       usage_json, cases_json, defects_json, updated_time, model, version)
+                    VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
                     """, rows)
                     """, rows)
             return len(tools)
             return len(tools)
         finally:
         finally:
@@ -262,16 +266,42 @@ def fetch_posts_grouped():
     return out
     return out
 
 
 
 
-def fetch_tools(q, case_id):
-    """从 fqe_tools 重建 {case_id, model, tool_count, tools:[...]}(对齐本地 tools/{case_id}.json)。
-    无记录返回 None。"""
+def fetch_tool_versions(q, case_id):
+    """列出某帖的全部解构版本(降序,最新在前)。无则返回 []。"""
+    if not _enabled():
+        return []
+    try:
+        conn = _conn()
+        try:
+            with conn.cursor() as cur:
+                cur.execute("SELECT DISTINCT version FROM fqe_tools WHERE q=%s AND case_id=%s "
+                            "ORDER BY version DESC", (q, case_id))
+                return [r["version"] for r in cur.fetchall() if r["version"]]
+        finally:
+            conn.close()
+    except Exception as ex:
+        print(f"⚠️ 读 fqe_tools 版本失败:{ex}")
+        return []
+
+
+def fetch_tools(q, case_id, version=None):
+    """从 fqe_tools 重建 {case_id, version, model, tool_count, tools:[...]}。
+    version=None 取最新版本(v_MMDDHHMM 字符串降序);指定则取该版本。无记录返回 None。"""
     if not _enabled():
     if not _enabled():
         return None
         return None
     try:
     try:
         conn = _conn()
         conn = _conn()
         try:
         try:
             with conn.cursor() as cur:
             with conn.cursor() as cur:
-                cur.execute("SELECT * FROM fqe_tools WHERE q=%s AND case_id=%s ORDER BY id", (q, case_id))
+                if version is None:
+                    cur.execute("SELECT version FROM fqe_tools WHERE q=%s AND case_id=%s "
+                                "ORDER BY version DESC, id DESC LIMIT 1", (q, case_id))
+                    row = cur.fetchone()
+                    if not row:
+                        return None
+                    version = row["version"]
+                cur.execute("SELECT * FROM fqe_tools WHERE q=%s AND case_id=%s AND version=%s "
+                            "ORDER BY id", (q, case_id, version))
                 rows = cur.fetchall()
                 rows = cur.fetchall()
         finally:
         finally:
             conn.close()
             conn.close()
@@ -287,7 +317,7 @@ def fetch_tools(q, case_id):
         "用法": _loads(r["usage_json"]), "案例": _loads(r["cases_json"]),
         "用法": _loads(r["usage_json"]), "案例": _loads(r["cases_json"]),
         "缺点": _loads(r["defects_json"]), "最新更新时间": r["updated_time"],
         "缺点": _loads(r["defects_json"]), "最新更新时间": r["updated_time"],
     } for r in rows]
     } for r in rows]
-    return {"case_id": case_id, "platform": rows[0]["platform"],
+    return {"case_id": case_id, "version": version, "platform": rows[0]["platform"],
             "title": rows[0]["post_title"], "model": rows[0]["model"],
             "title": rows[0]["post_title"], "model": rows[0]["model"],
             "tool_count": len(tools), "tools": tools}
             "tool_count": len(tools), "tools": tools}
 
 

+ 194 - 43
examples/process_pipeline/script/search_eval/fixed_query_eval/index.html

@@ -1382,7 +1382,6 @@
         <div class="navrow" id="navQRow" style="margin-bottom:4px;">
         <div class="navrow" id="navQRow" style="margin-bottom:4px;">
           <span class="navlab">Query</span>
           <span class="navlab">Query</span>
           <div id="navQ" style="display:flex;gap:8px;flex-wrap:wrap;flex:1"></div>
           <div id="navQ" style="display:flex;gap:8px;flex-wrap:wrap;flex:1"></div>
-          <button id="toolBatchBtn" onclick="openToolBatchModal()" style="margin-right:8px">🔧 工具解构</button>
           <button id="refresh2" onclick="loadData(true)">↻ 刷新 runs</button>
           <button id="refresh2" onclick="loadData(true)">↻ 刷新 runs</button>
         </div>
         </div>
         <div class="navrow" id="mxRow" style="margin-bottom:4px;">
         <div class="navrow" id="mxRow" style="margin-bottom:4px;">
@@ -1488,12 +1487,70 @@
 
 
   <!-- fixed_query_eval:图片放大灯箱(用 dialog 才能叠在详情 modal 之上)-->
   <!-- fixed_query_eval:图片放大灯箱(用 dialog 才能叠在详情 modal 之上)-->
   <style>
   <style>
-    #imgLightbox { border:none; background:transparent; padding:0; max-width:100vw; max-height:100vh; overflow:visible; }
-    #imgLightbox::backdrop { background: rgba(0,0,0,.85); }
-    #imgLightbox img { max-width:92vw; max-height:92vh; object-fit:contain; border-radius:8px; cursor:zoom-out; box-shadow:0 10px 40px rgba(0,0,0,.5); display:block; }
+    #imgLightbox { border:none; background:transparent; padding:0; width:100vw; height:100vh; max-width:100vw; max-height:100vh; overflow:hidden; }
+    #imgLightbox::backdrop { background: rgba(0,0,0,.88); }
+    #imgLightbox .lb-stage { width:100%; height:100%; display:flex; align-items:center; justify-content:center; position:relative; }
+    #imgLightbox .lb-stage img { max-width:86vw; max-height:90vh; object-fit:contain; border-radius:8px; box-shadow:0 10px 40px rgba(0,0,0,.5); cursor:pointer; display:block; }
+    #imgLightbox .lb-nav { position:absolute; top:50%; transform:translateY(-50%); width:50px; height:68px; border:none; border-radius:10px; background:rgba(255,255,255,.16); color:#fff; font-size:36px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background .15s; user-select:none; }
+    #imgLightbox .lb-nav:hover { background:rgba(255,255,255,.32); }
+    #imgLightbox .lb-prev { left:26px; } #imgLightbox .lb-next { right:26px; }
+    #imgLightbox .lb-nav.hidden { display:none; }
+    #imgLightbox .lb-counter { position:absolute; bottom:22px; left:50%; transform:translateX(-50%); color:#fff; font-size:13px; font-weight:600; background:rgba(0,0,0,.55); padding:5px 14px; border-radius:20px; }
+    #imgLightbox .lb-close { position:absolute; top:22px; right:26px; width:38px; height:38px; border:none; border-radius:50%; background:rgba(255,255,255,.16); color:#fff; font-size:18px; cursor:pointer; }
+    #imgLightbox .lb-close:hover { background:rgba(255,255,255,.32); }
+
+    /* fixed_query_eval:工具解构表格美化 + 单元格限高展开 */
+    .fqe-ttwrap { overflow-x:auto; border:1px solid #e6ded2; border-radius:10px; box-shadow:0 2px 12px rgba(0,0,0,.05); }
+    .fqe-tt { border-collapse:separate; border-spacing:0; width:100%; min-width:1180px; background:#fff; font-size:12.5px; }
+    .fqe-tt thead th {
+      position:sticky; top:0; z-index:2; text-align:left; white-space:nowrap;
+      background:linear-gradient(180deg,#2aa79b,#1c8076); color:#fff; font-weight:700;
+      padding:10px 12px; letter-spacing:.3px; border-right:1px solid rgba(255,255,255,.18);
+    }
+    .fqe-tt thead th:last-child { border-right:none; }
+    .fqe-tt tbody td { padding:9px 12px; vertical-align:top; line-height:1.6;
+      border-bottom:1px solid #f0eae0; border-right:1px solid #f5f0e8; color:#3a3a3a; }
+    .fqe-tt tbody td:last-child { border-right:none; }
+    /* 两层表头:第二行子表头下移吸顶;group/sub 表头样式 */
+    .fqe-tt2 thead tr:first-child th { top:0; }
+    .fqe-tt2 thead tr:nth-child(2) th { top:38px; }
+    .fqe-tt .th-group { text-align:center; }
+    .fqe-tt .th-sub { background:linear-gradient(180deg,#36bdb0,#23897f); font-weight:600; }
+    .fqe-tt td.col-case { background:#fafdfc; }
+    /* 斑马纹按「工具」分组(rowspan 安全),不用 nth-child */
+    .fqe-tt tbody tr.tr-b td { background:#fbfaf6; }
+    .fqe-tt tbody tr.tr-b td.col-case { background:#f6fbfa; }
+    .fqe-tt tbody td.col-tool { font-weight:700; color:#176d64; white-space:nowrap;
+      border-left:3px solid #2aa79b; background:#f3faf8; }
+    .fqe-tt tbody tr:hover td.col-tool { background:#e3f4f0; }
+    .fqe-tt ul { margin:0; padding-left:17px; }
+    .fqe-tt ul li { margin:3px 0; }
+    .fqe-tt ul li::marker { color:#2aa79b; }
+    .fqe-tt .layer-badge { display:inline-block; font-weight:700; font-size:11px; padding:2px 10px; border-radius:20px; white-space:nowrap; }
+    .fqe-tt .layer-badge.make { color:#0e7490; background:#d6f0ee; }
+    .fqe-tt .layer-badge.create { color:#b8731a; background:#fef0db; }
+    .fqe-tt .dash { color:#c9c2b6; }
+    /* 单元格限高 + 渐变蒙版 + 点击展开 */
+    .fqe-tt .tcell { position:relative; max-height:7.8em; overflow:hidden; transition:max-height .15s; }
+    .fqe-tt .tcell.clamped { cursor:zoom-in; }
+    .fqe-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; }
+    .fqe-tt tbody tr.tr-b .tcell.clamped::after { background:linear-gradient(rgba(251,250,246,0), #fbfaf6 72%); }
+    .fqe-tt td.col-case .tcell.clamped::after { background:linear-gradient(rgba(250,253,252,0), #fafdfc 72%); }
+    .fqe-tt tbody tr.tr-b td.col-case .tcell.clamped::after { background:linear-gradient(rgba(246,251,250,0), #f6fbfa 72%); }
+    .fqe-tt .tcell.open { max-height:none; cursor:zoom-out; }
+    .fqe-tt .tcell.open::after { content:''; height:0; }
   </style>
   </style>
-  <dialog id="imgLightbox" onclick="this.close()">
-    <img id="imgLightboxImg" referrerpolicy="no-referrer" alt="">
+  <dialog id="imgLightbox">
+    <div class="lb-stage" onclick="if(event.target===this) imgLightbox.close()">
+      <button class="lb-nav lb-prev" onclick="lightboxNav(-1)" aria-label="上一张">‹</button>
+      <img id="imgLightboxImg" referrerpolicy="no-referrer" alt="" onclick="lightboxNav(1)" title="点击看下一张">
+      <button class="lb-nav lb-next" onclick="lightboxNav(1)" aria-label="下一张">›</button>
+      <div class="lb-counter" id="lbCounter"></div>
+      <button class="lb-close" onclick="imgLightbox.close()" aria-label="关闭">✕</button>
+    </div>
   </dialog>
   </dialog>
 
 
   <!-- fixed_query_eval:批量工具解构 · 选帖弹层 -->
   <!-- fixed_query_eval:批量工具解构 · 选帖弹层 -->
@@ -2423,7 +2480,9 @@
       };
       };
       
       
       const filteredResults = f.results.filter(resultsFilter);
       const filteredResults = f.results.filter(resultsFilter);
-      const chans = ["all", ...f.platforms];
+      // 渠道从实际帖子的 platformKey 推导(不依赖 f.platforms,DB 回退时它为空),按 PLATC 顺序
+      const present = new Set(filteredResults.map(r => r.platformKey).filter(Boolean));
+      const chans = ["all", ...Object.keys(PLATC).filter(k => present.has(k))];
       document.getElementById("navC").innerHTML = chans.map(c => {
       document.getElementById("navC").innerHTML = chans.map(c => {
         const n = c === "all" ? filteredResults.length : filteredResults.filter(r => r.platformKey === c).length;
         const n = c === "all" ? filteredResults.length : filteredResults.filter(r => r.platformKey === c).length;
         return `<span class="tab ${c === st.channel ? 'on' : ''}" data-c="${c}">${c === "all" ? "全部" : (PLATC[c] || c)} <small>${n}</small></span>`;
         return `<span class="tab ${c === st.channel ? 'on' : ''}" data-c="${c}">${c === "all" ? "全部" : (PLATC[c] || c)} <small>${n}</small></span>`;
@@ -2853,12 +2912,36 @@
       }
       }
     }
     }
 
 
-    // fixed_query_eval:图片点击放大(灯箱用 dialog,叠在详情 modal 上层)
+    // fixed_query_eval:图片画廊灯箱(dialog 叠在详情 modal 上层,支持 ←→ 切换)
+    let _lbImgs = [], _lbIdx = 0;
     function openLightbox(src) {
     function openLightbox(src) {
-      const d = document.getElementById('imgLightbox');
-      document.getElementById('imgLightboxImg').src = src;
-      d.showModal();
-    }
+      const it = _curDetailItem();
+      _lbImgs = (it && Array.isArray(it.images) && it.images.length) ? it.images.slice() : [src];
+      _lbIdx = Math.max(0, _lbImgs.indexOf(src));
+      _lbRender();
+      document.getElementById('imgLightbox').showModal();
+    }
+    function _lbRender() {
+      if (!_lbImgs.length) return;
+      document.getElementById('imgLightboxImg').src = _lbImgs[_lbIdx];
+      const multi = _lbImgs.length > 1;
+      document.querySelectorAll('#imgLightbox .lb-nav').forEach(b => b.classList.toggle('hidden', !multi));
+      const ctr = document.getElementById('lbCounter');
+      ctr.textContent = `${_lbIdx + 1} / ${_lbImgs.length}`;
+      ctr.style.display = multi ? '' : 'none';
+    }
+    function lightboxNav(d) {
+      if (_lbImgs.length < 2) return;
+      _lbIdx = (_lbIdx + d + _lbImgs.length) % _lbImgs.length;   // 循环切换
+      _lbRender();
+    }
+    // 键盘 ←/→ 切换(ESC 关闭走 dialog 默认)
+    document.addEventListener('keydown', e => {
+      const lb = document.getElementById('imgLightbox');
+      if (!lb || !lb.open) return;
+      if (e.key === 'ArrowLeft') { e.preventDefault(); lightboxNav(-1); }
+      else if (e.key === 'ArrowRight') { e.preventDefault(); lightboxNav(1); }
+    });
 
 
     // ════════ fixed_query_eval:工具解构(批量选帖 + 单帖 + 结果渲染)════════
     // ════════ fixed_query_eval:工具解构(批量选帖 + 单帖 + 结果渲染)════════
     const TOOL_MODEL_LABEL = 'gemini-3.1-flash-lite';
     const TOOL_MODEL_LABEL = 'gemini-3.1-flash-lite';
@@ -2919,14 +3002,17 @@
     // ── 详情页「工具解构」tab ──
     // ── 详情页「工具解构」tab ──
     function _curDetailItem() { return VIEW[detailDialog.dataset.activeIdx]; }
     function _curDetailItem() { return VIEW[detailDialog.dataset.activeIdx]; }
 
 
+    let toolVersion = null;   // null = 最新版本;切到历史版本时设为具体版本号
     function loadToolsForCurrent() {
     function loadToolsForCurrent() {
       const it = _curDetailItem(); if (!it) return;
       const it = _curDetailItem(); if (!it) return;
       const pane = document.getElementById('modalContentTools');
       const pane = document.getElementById('modalContentTools');
       pane.innerHTML = '<div style="padding:40px;text-align:center;color:var(--muted)">加载中…</div>';
       pane.innerHTML = '<div style="padding:40px;text-align:center;color:var(--muted)">加载中…</div>';
-      fetch(`/api/tools_data?q=${encodeURIComponent(it.run)}&case_id=${encodeURIComponent(it.case_id)}`)
-        .then(r => r.json()).then(d => { pane.innerHTML = d.exists ? renderToolCards(d) : toolSetupHTML(); })
+      const vparam = toolVersion ? `&version=${encodeURIComponent(toolVersion)}` : '';
+      fetch(`/api/tools_data?q=${encodeURIComponent(it.run)}&case_id=${encodeURIComponent(it.case_id)}${vparam}`)
+        .then(r => r.json()).then(d => { pane.innerHTML = d.exists ? renderToolCards(d) : toolSetupHTML(); requestAnimationFrame(_markClampedCells); })
         .catch(() => { pane.innerHTML = '<div style="padding:40px;color:#c0392b">加载失败</div>'; });
         .catch(() => { pane.innerHTML = '<div style="padding:40px;color:#c0392b">加载失败</div>'; });
     }
     }
+    function changeToolVersion(v) { toolVersion = v || null; loadToolsForCurrent(); }
     function toolSetupHTML() {
     function toolSetupHTML() {
       return `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px 20px;">
       return `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px 20px;">
         <div style="font-size:36px;margin-bottom:14px;">🔧</div>
         <div style="font-size:36px;margin-bottom:14px;">🔧</div>
@@ -2949,6 +3035,7 @@
     }
     }
     function startToolExtractSingle() {
     function startToolExtractSingle() {
       const it = _curDetailItem(); if (!it) return;
       const it = _curDetailItem(); if (!it) return;
+      toolVersion = null;   // 新解构 → 完成后看最新版本
       const pane = document.getElementById('modalContentTools');
       const pane = document.getElementById('modalContentTools');
       pane.innerHTML = `<div style="padding:48px;text-align:center;color:var(--muted)">⏳ 正在解构本帖工具…(${TOOL_MODEL_LABEL})</div>`;
       pane.innerHTML = `<div style="padding:48px;text-align:center;color:var(--muted)">⏳ 正在解构本帖工具…(${TOOL_MODEL_LABEL})</div>`;
       fetch('/api/extract_tools', { method: 'POST', headers: { 'Content-Type': 'application/json' },
       fetch('/api/extract_tools', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -2960,7 +3047,8 @@
     }
     }
     function reExtractTools() {
     function reExtractTools() {
       const it = _curDetailItem(); if (!it) return;
       const it = _curDetailItem(); if (!it) return;
-      if (!confirm('重新解构会覆盖已有结果,确定?')) return;
+      if (!confirm('重新解构会生成一个新版本(旧版本保留,可在版本下拉里切回),确定?')) return;
+      toolVersion = null;   // 解构后看最新版本
       const pane = document.getElementById('modalContentTools');
       const pane = document.getElementById('modalContentTools');
       pane.innerHTML = `<div style="padding:48px;text-align:center;color:var(--muted)">⏳ 重新解构中…(${TOOL_MODEL_LABEL})</div>`;
       pane.innerHTML = `<div style="padding:48px;text-align:center;color:var(--muted)">⏳ 重新解构中…(${TOOL_MODEL_LABEL})</div>`;
       fetch('/api/extract_tools', { method: 'POST', headers: { 'Content-Type': 'application/json' },
       fetch('/api/extract_tools', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -2974,16 +3062,37 @@
         : `<span>${esc(String(val))}</span>`;
         : `<span>${esc(String(val))}</span>`;
       return `<div style="margin-top:8px;font-size:13px;line-height:1.5;"><span style="font-size:12px;font-weight:700;color:${color || 'var(--muted)'};">${label}</span> ${body}</div>`;
       return `<div style="margin-top:8px;font-size:13px;line-height:1.5;"><span style="font-size:12px;font-weight:700;color:${color || 'var(--muted)'};">${label}</span> ${body}</div>`;
     }
     }
-    let toolViewMode = 'cards';   // 'cards' | 'table'
+    // 案例:新结构是 [{输入, 输出, 效果}] 对象数组(旧的可能是字符串,兼容)
+    function _renderCaseList(cases) {
+      return (cases || []).map(c => {
+        if (c === null || typeof c !== 'object') return `<li style="margin:2px 0;">${esc(String(c))}</li>`;
+        const parts = [];
+        if (c['输入']) parts.push(`<b>输入</b>:${esc(c['输入'])}`);
+        if (c['输出']) parts.push(`<b>输出</b>:${esc(c['输出'])}`);
+        if (c['效果']) parts.push(`<b>效果</b>:${esc(c['效果'])}`);
+        return `<li style="margin:3px 0;">${parts.join(';') || esc(JSON.stringify(c))}</li>`;
+      }).join('');
+    }
+    function _renderCaseBlock(cases) {   // 卡片视图用
+      if (!Array.isArray(cases) || !cases.length) return '';
+      return `<div style="margin-top:8px;font-size:13px;line-height:1.5;"><span style="font-size:12px;font-weight:700;color:var(--muted);">案例</span><ul style="margin:2px 0 0;padding-left:18px;">${_renderCaseList(cases)}</ul></div>`;
+    }
+    let toolViewMode = 'table';   // 'cards' | 'table'(默认表格)
     let _lastToolsData = null;
     let _lastToolsData = null;
 
 
     function renderToolCards(d) {
     function renderToolCards(d) {
       _lastToolsData = d;
       _lastToolsData = d;
       const tools = d.tools || [];
       const tools = d.tools || [];
       const toggleLabel = toolViewMode === 'table' ? '▤ 卡片视图' : '⊞ 表格视图';
       const toggleLabel = toolViewMode === 'table' ? '▤ 卡片视图' : '⊞ 表格视图';
-      const header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
+      const versions = d.versions || [];
+      const curVer = d.version || versions[0] || '';
+      const verSel = versions.length
+        ? `<select onchange="changeToolVersion(this.value)" title="切换历史版本" style="font-size:12px;padding:3px 8px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer;">${versions.map(v => `<option value="${esc(v)}" ${v === curVer ? 'selected' : ''}>${esc(v)}${v === versions[0] ? '(最新)' : ''}</option>`).join('')}</select>`
+        : (curVer ? `<span style="font-size:12px;color:var(--muted);">版本 ${esc(curVer)}</span>` : '');
+      const header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:10px;">
         <div style="font-size:14px;color:var(--muted);">共 <b style="color:var(--ink);">${tools.length}</b> 个工具 · 模型 ${esc(d.model || TOOL_MODEL_LABEL)}</div>
         <div style="font-size:14px;color:var(--muted);">共 <b style="color:var(--ink);">${tools.length}</b> 个工具 · 模型 ${esc(d.model || TOOL_MODEL_LABEL)}</div>
-        <div style="display:flex;gap:8px;">
+        <div style="display:flex;gap:8px;align-items:center;flex-shrink:0;">
+          ${verSel}
           <button onclick="toggleToolView()" style="font-size:12px;">${toggleLabel}</button>
           <button onclick="toggleToolView()" style="font-size:12px;">${toggleLabel}</button>
           <button onclick="reExtractTools()" style="font-size:12px;">↻ 重新解构</button>
           <button onclick="reExtractTools()" style="font-size:12px;">↻ 重新解构</button>
         </div>
         </div>
@@ -2994,7 +3103,10 @@
 
 
     function toggleToolView() {
     function toggleToolView() {
       toolViewMode = toolViewMode === 'table' ? 'cards' : 'table';
       toolViewMode = toolViewMode === 'table' ? 'cards' : 'table';
-      if (_lastToolsData) document.getElementById('modalContentTools').innerHTML = renderToolCards(_lastToolsData);
+      if (_lastToolsData) {
+        document.getElementById('modalContentTools').innerHTML = renderToolCards(_lastToolsData);
+        requestAnimationFrame(_markClampedCells);
+      }
     }
     }
 
 
     function renderToolCardsBody(tools) {
     function renderToolCardsBody(tools) {
@@ -3015,7 +3127,7 @@
           ${(tags.length || updated) ? `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px;">${tags.join('')} ${updated}</div>` : ''}
           ${(tags.length || updated) ? `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px;">${tags.join('')} ${updated}</div>` : ''}
           <div style="display:flex;gap:28px;flex-wrap:wrap;">${_toolField('输入', t['输入'])}${_toolField('输出', t['输出'])}</div>
           <div style="display:flex;gap:28px;flex-wrap:wrap;">${_toolField('输入', t['输入'])}${_toolField('输出', t['输出'])}</div>
           ${_toolField('用法', t['用法'])}
           ${_toolField('用法', t['用法'])}
-          ${_toolField('案例', t['案例'])}
+          ${_renderCaseBlock(t['案例'])}
           ${_toolField('缺点', t['缺点'], '#b8651a')}
           ${_toolField('缺点', t['缺点'], '#b8651a')}
           ${link}
           ${link}
         </div>`;
         </div>`;
@@ -3028,38 +3140,77 @@
       if (Array.isArray(v)) return '<ul style="margin:0;padding-left:15px;">' + v.map(x => `<li style="margin:1px 0;">${esc(String(x))}</li>`).join('') + '</ul>';
       if (Array.isArray(v)) return '<ul style="margin:0;padding-left:15px;">' + v.map(x => `<li style="margin:1px 0;">${esc(String(x))}</li>`).join('') + '</ul>';
       return esc(String(v));
       return esc(String(v));
     }
     }
+    const DASH = '<span class="dash">—</span>';
+    // 单元格内容包一层 .tcell(可限高+点击展开);不可点的列(链接/徽章/工具名)不包
+    function _ttCell(inner, clampable) {
+      return clampable ? `<div class="tcell" onclick="this.classList.toggle('open')">${inner}</div>` : inner;
+    }
+    // 普通列(案例 group 之外)的单元格内容
+    function _toolCellContent(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>` : DASH;
+      } else if (c === '创作层级') {
+        clampable = false;
+        inner = t[c] ? `<span class="layer-badge ${t[c] === '制作层' ? 'make' : 'create'}">${esc(t[c])}</span>` : DASH;
+      } else {
+        const v = _toolCell(t[c]);
+        inner = (v === '<span style="color:#c4c4c4">—</span>') ? DASH : v;
+      }
+      if (['输入', '输出', '用法', '缺点'].includes(c)) style = 'max-width:240px;';
+      else if (!clampable) style = 'white-space:nowrap;';
+      return { inner, cls, clampable, style };
+    }
+    function _td(c, t, rowspan) {
+      const { inner, cls, clampable, style } = _toolCellContent(c, t);
+      const rs = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
+      return `<td class="${cls}" style="${style}"${rs}>${_ttCell(inner, clampable)}</td>`;
+    }
+    function _caseTd(cse, key) {
+      const v = (cse && cse[key] != null && cse[key] !== '') ? esc(String(cse[key])) : DASH;
+      return `<td class="col-case" style="max-width:210px;">${_ttCell(v, true)}</td>`;
+    }
     function renderToolTable(tools) {
     function renderToolTable(tools) {
-      const cols = ['工具名称', '创作层级', '实质作用域', '形式作用域', '输入', '输出', '用法', '案例', '缺点', '来源链接', '最新更新时间'];
-      const tdBase = 'border:1px solid var(--line);padding:7px 9px;vertical-align:top;font-size:12px;line-height:1.5;';
-      const th = cols.map(c =>
-        `<th style="${tdBase}background:#faf7f1;font-weight:700;white-space:nowrap;position:sticky;top:0;z-index:1;">${c}</th>`
-      ).join('');
-      const rows = tools.map(t => {
-        const tds = cols.map(c => {
-          let inner;
-          if (c === '来源链接') {
-            inner = t[c] ? `<a href="${esc(t[c])}" target="_blank" style="color:#2563eb;">🔗 打开</a>` : '<span style="color:#c4c4c4">—</span>';
-          } else if (c === '创作层级' && t[c]) {
-            const lc = t[c] === '制作层' ? '#0e7490' : '#b87918';
-            const lbg = t[c] === '制作层' ? '#e0f2f1' : '#fef3e2';
-            inner = `<span style="font-weight:700;color:${lc};background:${lbg};padding:1px 8px;border-radius:12px;white-space:nowrap;">${esc(t[c])}</span>`;
-          } else {
-            inner = _toolCell(t[c]);
+      // 案例 group(输入/输出/效果)放在 用法 后、缺点 前;用 colspan/rowspan 做两层表头
+      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 rows = tools.map((t, ti) => {
+        const cases = (Array.isArray(t['案例']) && t['案例'].length) ? t['案例'] : [null];
+        const K = cases.length;
+        const par = ti % 2 ? 'tr-b' : 'tr-a';
+        return cases.map((cse, i) => {
+          const caseTds = `${_caseTd(cse, '输入')}${_caseTd(cse, '输出')}${_caseTd(cse, '效果')}`;
+          if (i === 0) {
+            return `<tr class="${par}">${before.map(c => _td(c, t, K)).join('')}${caseTds}${after.map(c => _td(c, t, K)).join('')}</tr>`;
           }
           }
-          const mw = ['用法', '案例', '缺点', '输入', '输出'].includes(c) ? 'min-width:140px;' : (c === '工具名称' ? 'min-width:100px;font-weight:600;white-space:nowrap;' : 'white-space:nowrap;');
-          return `<td style="${tdBase}${mw}">${inner}</td>`;
+          return `<tr class="${par}">${caseTds}</tr>`;
         }).join('');
         }).join('');
-        return `<tr>${tds}</tr>`;
       }).join('');
       }).join('');
-      return `<div style="overflow-x:auto;border:1px solid var(--line);border-radius:8px;">
-        <table style="border-collapse:collapse;width:100%;min-width:900px;background:#fff;">
-          <thead><tr>${th}</tr></thead><tbody>${rows}</tbody>
-        </table></div>`;
+      return `<div class="fqe-ttwrap"><table class="fqe-tt fqe-tt2">${thead}<tbody>${rows}</tbody></table></div>`;
+    }
+    // 渲染后标记真正溢出的单元格(才显示蒙版+可点击)
+    function _markClampedCells() {
+      document.querySelectorAll('#modalContentTools .fqe-tt .tcell').forEach(el => {
+        if (!el.classList.contains('open') && el.scrollHeight > el.clientHeight + 2) el.classList.add('clamped');
+        else if (el.scrollHeight <= el.clientHeight + 2) el.classList.remove('clamped');
+      });
     }
     }
 
 
     function openDetail(i) {
     function openDetail(i) {
       const it = VIEW[i];
       const it = VIEW[i];
       detailDialog.dataset.activeIdx = i;
       detailDialog.dataset.activeIdx = i;
+      toolVersion = null;   // 新帖 → 工具 tab 默认看最新版本
       currentPinnedScoreEl = null;
       currentPinnedScoreEl = null;
       document.getElementById("modalMeta").innerHTML = `<span class="platform p-${esc(it.platformKey)}">${esc(it.platform)}</span><span>Query ID: <b style="font-family: monospace; color: var(--amber); background: var(--soft-amber); border: 1px solid rgba(184, 121, 24, 0.2); padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-right: 8px;">${esc(it.run)}</b></span><span>${esc(it.date)} · ${esc(it.engagement)} · 质量 ${esc(it.grade)} ${esc(it.qscore)}</span>`;
       document.getElementById("modalMeta").innerHTML = `<span class="platform p-${esc(it.platformKey)}">${esc(it.platform)}</span><span>Query ID: <b style="font-family: monospace; color: var(--amber); background: var(--soft-amber); border: 1px solid rgba(184, 121, 24, 0.2); padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-right: 8px;">${esc(it.run)}</b></span><span>${esc(it.date)} · ${esc(it.engagement)} · 质量 ${esc(it.grade)} ${esc(it.qscore)}</span>`;
       document.getElementById("modalTitle").textContent = it.title;
       document.getElementById("modalTitle").textContent = it.title;

+ 40 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/prompts/tool_extract_system.md

@@ -0,0 +1,40 @@
+你是一个内容知识提取助手,将网络帖子(公众号文章、视频号图文、短视频等)中的工具信息提炼为结构化知识条目。
+
+提取规则:
+
+- **图片 / 视频帧里的信息必须逐字提取成文字**——教程的真正干货(提示词原文、参数数值、按钮/菜单名、步骤顺序、操作前后的对比图说明)几乎都在配图里,不要只看正文。
+- **保留具体细节,不要抽象化**:看到提示词就抄原文,看到参数就记数值,看到操作就写清是哪个按钮/功能、第几步。
+- 去除废话:广告语、情绪渲染、关注引导、重复内容、无实质信息的过渡句。
+- 一篇帖子中每提到一个工具,输出一条独立知识条目。
+- 所有字段无信息则填 null,**只提取帖子真实出现的内容,不猜测、不杜撰、不补全**。区分「帖子明说的」和「你推断的」——只要后者一律不写。
+
+---
+
+请识别内容中提到的所有工具。**只输出一个 JSON 对象**,格式如下(每个工具一个对象,放进 tools 数组):
+
+{
+"tools": [
+{
+"工具名称": string,
+"实质作用域": string, // 工具适合处理的内容主体,如「人物」「产品」「风景」,如果作者提及,从提及中提取,未直接提及从案例、prompt中总结;若未提及,填「实拍图片」或「信息图片」「视频」等泛称,
+"形式作用域": string | null, // 工具擅长的风格或表现形式,如「实拍氛围感」「写实」「动漫」;如果作者提及,从提及中提取,未直接提及从案例、prompt中总结;若未提及填 null
+"创作层级": "制作层" | "创作层", // 制作层 = 生产内容(出图/出视频/剪辑/配音);创作层 = 辅助方向(选题/灵感/脚本)
+"来源链接": string | null,
+"输入": string | null, // 该工具需要喂入什么素材类型
+"输出": string | null, // 该工具产出什么,如「底图」「渲染图」
+"用法": string[] | null, // 工具的用法,如参数如何填写,prompt 如何写,
+"案例": [ // 帖子里展示的具体复现实例(输入→输出的完整链路)数组。注意很多案例都在图片里,需要仔细甄别;无则 null
+{
+"输入": string, // 具体输入了什么,特别是用了什么提示词
+"输出": string, // 最终产出了什么图 / 视频 / 文件
+"效果": string | null // 达成的具体效果、或操作前后的对比差异;无则 null
+}
+],
+"缺点": string[] | null, // 帖子中提到的工具局限、不足或注意事项,每条为一个独立缺点
+"最新更新时间": string | null
+}
+]
+}
+
+若识别到多个工具,tools 数组中包含多个对象。若未识别到任何工具,输出 {"tools": []}。
+不要输出 JSON 以外的任何内容(不要 markdown 代码块标记、不要解释)。

+ 26 - 18
examples/process_pipeline/script/search_eval/fixed_query_eval/server.py

@@ -654,29 +654,37 @@ class H(BaseHTTPRequestHandler):
                 "done": done, "running": running, "error": task.get("error"),
                 "done": done, "running": running, "error": task.get("error"),
             }, ensure_ascii=False), "application/json")
             }, ensure_ascii=False), "application/json")
         elif path == "/api/tools_data":
         elif path == "/api/tools_data":
-            # 取某帖的工具解构结果
+            # 取某帖的工具解构结果。可选 version:指定则取该版本(只从库),否则取最新(本地优先)。
+            # 始终附带 versions(全部历史版本,供前端下拉切换)。
             q = (params.get("q") or [""])[0].strip()
             q = (params.get("q") or [""])[0].strip()
             case_id = (params.get("case_id") or [""])[0].strip()
             case_id = (params.get("case_id") or [""])[0].strip()
+            version = (params.get("version") or [""])[0].strip() or None
             if not q or not case_id:
             if not q or not case_id:
                 self._send(400, "missing q or case_id", "text/plain"); return
                 self._send(400, "missing q or case_id", "text/plain"); return
-            f = HERE / "runs_full" / q / "tools" / f"{case_id}.json"
-            if not f.is_file():
-                # 本地无 → 回退读库重建
-                try:
-                    import db
-                    dbdata = db.fetch_tools(q, case_id)
-                except Exception:
-                    dbdata = None
-                if dbdata:
-                    dbdata["exists"] = True
-                    self._send(200, json.dumps(dbdata, ensure_ascii=False), "application/json"); return
-                self._send(200, json.dumps({"exists": False}, ensure_ascii=False), "application/json"); return
             try:
             try:
-                data = json.loads(f.read_text(encoding="utf-8"))
-                data["exists"] = True
-                self._send(200, json.dumps(data, ensure_ascii=False), "application/json")
-            except Exception as e:
-                self._send(500, json.dumps({"error": f"read failed: {e}"}, ensure_ascii=False), "application/json")
+                import db
+                versions = db.fetch_tool_versions(q, case_id)
+            except Exception:
+                db, versions = None, []
+
+            def _emit(data):
+                if data:
+                    data["exists"] = True
+                    data["versions"] = versions
+                    self._send(200, json.dumps(data, ensure_ascii=False), "application/json")
+                else:
+                    self._send(200, json.dumps({"exists": False, "versions": versions}, ensure_ascii=False), "application/json")
+
+            if version:                                  # 指定历史版本 → 只能从库取
+                _emit(db.fetch_tools(q, case_id, version) if db else None); return
+
+            f = HERE / "runs_full" / q / "tools" / f"{case_id}.json"   # 默认最新:本地优先
+            if f.is_file():
+                try:
+                    _emit(json.loads(f.read_text(encoding="utf-8"))); return
+                except Exception as e:
+                    self._send(500, json.dumps({"error": f"read failed: {e}"}, ensure_ascii=False), "application/json"); return
+            _emit(db.fetch_tools(q, case_id) if db else None)          # 本地无 → 库最新
         elif path == "/api/procedure_status":
         elif path == "/api/procedure_status":
             q = (params.get("q") or [""])[0].strip()
             q = (params.get("q") or [""])[0].strip()
             form = (params.get("form") or [""])[0].strip()
             form = (params.get("form") or [""])[0].strip()

+ 113 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/shell/README.md

@@ -0,0 +1,113 @@
+# search_eval 查看服务 + 公网分享 使用说明
+
+把本地的搜索评估查看服务(`server.py`,默认 `:8770`)一键起好,并通过 Cloudflare 隧道生成一个**公网链接**,发给任何人就能看 —— 无需对方在同一局域网、无需对方改代理设置。
+
+脚本:`share.sh`(本目录)
+
+---
+
+## 快速开始
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/process_pipeline/script/search_eval/fixed_query_eval/shell
+
+./share.sh start      # 起服务 + 隧道,打印公网链接
+```
+
+执行后会输出类似:
+
+```
+🌐 公网链接(发给同事即可,无需局域网/改代理):
+    https://advanced-how-leasing-brief.trycloudflare.com
+```
+
+把这个链接发到群里即可。**进程一直保留**(已脱离终端),关掉终端窗口也不会断。
+
+---
+
+## 全部命令
+
+| 命令 | 作用 |
+|------|------|
+| `./share.sh start`   | 起 `server.py`(后台)+ cloudflared 隧道(后台,http2),打印公网链接 |
+| `./share.sh stop`    | 停掉服务和隧道 |
+| `./share.sh restart` | 重启两者(**会换一个新链接**,记得重新发) |
+| `./share.sh status`  | 查看运行状态 + 当前链接 + 本地自检(HTTP 状态码) |
+| `./share.sh url`     | 只打印当前公网链接(方便复制) |
+
+`start` 是幂等的:服务已在跑就跳过,不会重复起进程,可放心反复执行当作"确保它在跑"。
+
+---
+
+## 典型场景
+
+**第一次分享 / 重新分享:**
+```bash
+./share.sh start          # 拿到链接,发群
+```
+
+**链接突然打不开了(先别慌,一条命令查):**
+```bash
+./share.sh status
+```
+- `server.py ❌` → 后端挂了,执行 `./share.sh start` 拉起来。
+- `cloudflared ❌` 或链接变了 → 隧道断了,执行 `./share.sh restart`(换新链接)。
+- 两个都 ✅ 但对方打不开 → 多半是对方浏览器/系统代理拦截,见下方"常见问题"。
+
+**忘了链接是多少:**
+```bash
+./share.sh url
+```
+
+**用完关闭分享:**
+```bash
+./share.sh stop
+```
+
+---
+
+## 它替你规避的坑(为什么不直接用局域网 IP / 不直接 `python server.py`)
+
+| 坑 | 现象 | 脚本的处理 |
+|----|------|-----------|
+| 前台运行,关终端被 SIGHUP 杀掉 | 关窗口/休眠后链接 502 | 用 `setsid`/`nohup` 脱离终端守护 |
+| QUIC/UDP 被公司网或 VPN 封 | 隧道死循环重连,公网打不开 | 固定 `--protocol http2`,走 TCP 443 |
+| 局域网共享要求同网段 | WiFi 客户端隔离 → ping 不通 | 走公网隧道,完全绕开局域网 |
+| 访客本地代理拦截内网 IP | 同事打不开 `192.168.x.x` | 公网 HTTPS,访客代理正常放行 |
+| 重复启动起一堆进程 | 端口冲突/混乱 | `start` 先检测端口,幂等 |
+
+---
+
+## 常见问题
+
+**Q:链接打开是 502 / Bad Gateway?**
+后端 `server.py` 没在跑(隧道只是转发器)。`./share.sh status` 确认,再 `./share.sh start`。
+
+**Q:对方打不开,但我本地和 `status` 都正常?**
+对方浏览器或系统开了代理(Clash/V2Ray 等),把公网域名也拦了。让对方临时关代理,或把 `trycloudflare.com` 加进代理白名单/直连规则。
+
+**Q:链接每次重启都变,能不能固定?**
+`trycloudflare` 是临时地址,绑定在 cloudflared 进程生命周期上,进程停就失效、重启换新。要**永久固定地址**需要 Cloudflare 账号 + 自有域名做 named tunnel —— 需要时再单独配置。
+
+**Q:日志在哪?**
+- 服务日志:`../.server.log`
+- 隧道日志:`../.cloudflared.log`
+(都在 `fixed_query_eval/` 目录下,以 `.` 开头隐藏。)
+
+**Q:换个端口?**
+```bash
+PORT=9000 ./share.sh start
+```
+
+**Q:用指定的 python 解释器?**
+```bash
+PYTHON=/path/to/python ./share.sh start
+```
+
+---
+
+## 依赖
+
+- `cloudflared`(已安装;如缺失:`brew install cloudflared`)
+- `python`(项目当前用 conda `base` 环境的 `python`)
+- `lsof` / `curl`(macOS 自带)

+ 131 - 0
examples/process_pipeline/script/search_eval/fixed_query_eval/shell/share.sh

@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# ──────────────────────────────────────────────────────────────────────────
+# search_eval 查看服务 + Cloudflare 隧道 一键管理脚本
+#
+#   ./share.sh start     启动 server.py(后台) + cloudflared 隧道(后台,http2),打印公网链接
+#   ./share.sh stop      停掉两者
+#   ./share.sh restart   重启两者(换新链接)
+#   ./share.sh status     查看运行状态 + 当前链接
+#   ./share.sh url        只打印当前公网链接
+#
+# 设计要点:
+#   - 两个进程都用 nohup/setsid 脱离终端,关掉终端窗口也不会被 SIGHUP 杀掉。
+#   - cloudflared 强制 --protocol http2,绕开公司网/VPN 常封的 UDP(QUIC)。
+#   - 幂等:重复 start 会先清掉旧进程,不会起一堆。
+# ──────────────────────────────────────────────────────────────────────────
+set -euo pipefail
+
+# 项目目录 = 本脚本所在目录(shell/)的上一级
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJ_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+PORT="${PORT:-8770}"
+
+SERVER_LOG="$PROJ_DIR/.server.log"
+CF_LOG="$PROJ_DIR/.cloudflared.log"
+SERVER_PIDF="$PROJ_DIR/.server.pid"
+CF_PIDF="$PROJ_DIR/.cloudflared.pid"
+
+PY="${PYTHON:-python}"
+
+# 选一个能后台脱离的方式:优先 setsid,macOS 没有就用 nohup
+_spawn() {  # _spawn <logfile> <cmd...>
+  local log="$1"; shift
+  if command -v setsid >/dev/null 2>&1; then
+    setsid "$@" >"$log" 2>&1 < /dev/null &
+  else
+    nohup "$@" >"$log" 2>&1 < /dev/null &
+  fi
+  echo $!
+}
+
+_alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; }
+
+_server_running() { lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; }
+
+_extract_url() {
+  # 优先看脚本自己的日志,兼容回退到手动启动时的 /tmp 日志;没找到不报错
+  { grep -hEo "https://[a-z0-9-]+\.trycloudflare\.com" "$CF_LOG" /tmp/cf_tunnel.log 2>/dev/null || true; } | tail -1
+}
+
+start_server() {
+  if _server_running; then
+    echo "✅ server.py 已在跑 (:$PORT)"
+    return
+  fi
+  echo "▶️  启动 server.py …"
+  ( cd "$PROJ_DIR" && _spawn "$SERVER_LOG" "$PY" server.py "$PORT" ) > "$SERVER_PIDF"
+  # 等监听端口起来
+  for _ in $(seq 1 20); do
+    _server_running && break
+    sleep 0.5
+  done
+  if _server_running; then
+    echo "✅ server.py 已启动 (:$PORT)  日志: $SERVER_LOG"
+  else
+    echo "❌ server.py 启动失败,看日志: $SERVER_LOG"; tail -15 "$SERVER_LOG" || true; exit 1
+  fi
+}
+
+start_tunnel() {
+  echo "▶️  启动 cloudflared 隧道 (http2) …"
+  : > "$CF_LOG"
+  _spawn "$CF_LOG" cloudflared tunnel --protocol http2 --url "http://localhost:$PORT" > "$CF_PIDF"
+  # 等 URL 出现
+  local url=""
+  for _ in $(seq 1 40); do
+    url="$(_extract_url)"
+    [ -n "$url" ] && break
+    sleep 0.5
+  done
+  if [ -n "$url" ]; then
+    # 再等连接注册成功
+    for _ in $(seq 1 20); do
+      grep -q "Registered tunnel connection" "$CF_LOG" && break
+      sleep 0.5
+    done
+    echo
+    echo "🌐 公网链接(发给同事即可,无需局域网/改代理):"
+    echo "    $url"
+    echo
+  else
+    echo "❌ 隧道未拿到地址,看日志: $CF_LOG"; tail -15 "$CF_LOG" || true; exit 1
+  fi
+}
+
+stop_all() {
+  echo "⏹  停止隧道与服务 …"
+  pkill -f "cloudflared tunnel --protocol http2 --url http://localhost:$PORT" 2>/dev/null && echo "  - cloudflared 已停" || echo "  - 无 cloudflared"
+  # 杀掉占用端口的 server.py
+  local pids; pids="$(lsof -nP -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true)"
+  if [ -n "$pids" ]; then
+    echo "$pids" | xargs kill 2>/dev/null && echo "  - server.py (:$PORT) 已停"
+  else
+    echo "  - 无 server.py 监听 :$PORT"
+  fi
+  rm -f "$SERVER_PIDF" "$CF_PIDF"
+}
+
+status() {
+  echo "── 状态 ──────────────────────────────"
+  if _server_running; then echo "server.py : ✅ 运行中 (:$PORT)"; else echo "server.py : ❌ 未运行"; fi
+  if pgrep -f "cloudflared tunnel .*localhost:$PORT" >/dev/null 2>&1; then
+    echo "cloudflared: ✅ 运行中"
+  else
+    echo "cloudflared: ❌ 未运行"
+  fi
+  local url; url="$(_extract_url)"
+  [ -n "$url" ] && echo "公网链接   : $url" || echo "公网链接   : (无)"
+  # 本地健康检查(绕代理)
+  local code; code="$(curl -s -m3 --noproxy '*' -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/" 2>/dev/null || echo 000)"
+  echo "本地自检   : HTTP $code"
+  echo "─────────────────────────────────────"
+}
+
+case "${1:-start}" in
+  start)   start_server; start_tunnel ;;
+  stop)    stop_all ;;
+  restart) stop_all; sleep 1; start_server; start_tunnel ;;
+  status)  status ;;
+  url)     u="$(_extract_url)"; [ -n "$u" ] && echo "$u" || { echo "(隧道未运行)"; exit 1; } ;;
+  *) echo "用法: $0 {start|stop|restart|status|url}"; exit 1 ;;
+esac

+ 11 - 55
examples/process_pipeline/script/search_eval/fixed_query_eval/tool_extract.py

@@ -18,6 +18,7 @@ import argparse
 import asyncio
 import asyncio
 import json
 import json
 import sys
 import sys
+from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 PROJECT_ROOT = Path(__file__).resolve().parents[5]   # …/Agent
 PROJECT_ROOT = Path(__file__).resolve().parents[5]   # …/Agent
@@ -42,57 +43,8 @@ DEFAULT_MODEL_CHOICE = "gemini-flash-lite"   # → google/gemini-3.1-flash-lite
 MAX_IMAGES = 6
 MAX_IMAGES = 6
 
 
 
 
-# ── 解构 Prompt(用户指定;仅把输出容器从裸数组改成 {"tools":[…]} 以适配 JSON 对象提取器)──
-
-TOOL_SYSTEM = """你是一个内容知识提取助手,将网络帖子(公众号文章、视频号图文、短视频等)中的工具信息提炼为「可复现」的结构化知识条目。
-
-你的输出会被别人当作操作手册来照着复现,所以核心目标是:**读者只看你的「用法」和「案例」,就能还原出帖子里展示的效果**。宁可信息多,不要丢失任何可操作的细节。
-
-提取规则:
-- **图片 / 视频帧里的信息必须逐字提取成文字**——教程的真正干货(提示词原文、参数数值、按钮/菜单名、步骤顺序、操作前后的对比图说明)几乎都在配图里,不要只看正文。
-- **保留具体细节,不要抽象化**:看到提示词就抄原文(中英文照搬),看到参数就记数值,看到操作就写清是哪个按钮/功能、第几步。禁止把「输入提示词 xxx 生成底图」压缩成「用提示词生成底图」这种丢失原文的空话。
-- 去除废话:广告语、情绪渲染、关注引导、重复内容、无实质信息的过渡句。
-- 一篇帖子中每提到一个工具,输出一条独立知识条目。
-- 所有字段无信息则填 null,**只提取帖子真实出现的内容,不猜测、不杜撰、不补全**。区分「帖子明说的」和「你推断的」——只要后者一律不写。
-
----
-
-请识别内容中提到的所有工具。**只输出一个 JSON 对象**,格式如下(每个工具一个对象,放进 tools 数组):
-
-{
-  "tools": [
-    {
-      "工具名称": string,
-      "实质作用域": string,       // 工具适合处理的内容主体,如「人物」「产品」「风景」;若未提及,填「图片」或「视频」等泛称
-      "形式作用域": string | null,  // 工具擅长的风格或表现形式,如「氛围感」「写实」「动漫」;若未提及填 null
-      "创作层级": "制作层" | "创作层",  // 制作层 = 生产内容(出图/出视频/剪辑/配音);创作层 = 辅助方向(选题/灵感/脚本)
-      "来源链接": string | null,
-      "输入": string | null,        // 该工具需要喂入什么(素材类型 + 提示词),如「角色参考图、光影参考图、提示词」
-      "输出": string | null,        // 该工具产出什么,如「底图」「渲染图」
-      "用法": [                      // 可复现的操作步骤数组;每一步都要让读者能照做。无则 null
-        {
-          "步骤": string,           // 这一步在做什么操作(动作 + 用到的按钮/功能/模式)
-          "提示词或参数": string | null,  // 这一步用到的提示词原文、参数数值、设置项;逐字抄,无则 null
-          "目的": string | null     // 这一步是为了达成什么效果 / 解决什么问题;让读者理解为什么这么做
-        }
-      ],
-      "案例": [                      // 帖子里展示的具体复现实例(输入→操作→输出的完整链路)数组。无则 null
-        {
-          "场景": string,           // 这个案例要做成什么,如「生成粉色氛围的角色肖像」
-          "输入": string,           // 具体喂了什么:垫了哪些参考图(几张/什么图)+ 用的提示词原文
-          "使用的用法": string,     // 用了本工具(或配合其它工具)的哪个用法/按钮,按操作顺序串起来
-          "输出": string,           // 最终产出了什么图 / 视频 / 文件
-          "效果": string | null     // 达成的具体效果、或操作前后的对比差异;无则 null
-        }
-      ],
-      "缺点": string[] | null,       // 帖子中提到的工具局限、不足或注意事项,每条为一个独立缺点
-      "最新更新时间": string | null
-    }
-  ]
-}
-
-若识别到多个工具,tools 数组中包含多个对象。若未识别到任何工具,输出 {"tools": []}。
-不要输出 JSON 以外的任何内容(不要 markdown 代码块标记、不要解释)。"""
+# ── 解构 Prompt:从外部文件加载(见 prompts/tool_extract_system.md,便于单独迭代,不塞代码里)──
+TOOL_SYSTEM = (HERE / "prompts" / "tool_extract_system.md").read_text(encoding="utf-8")
 
 
 TOOL_USER_PREFIX = "【内容】\n"
 TOOL_USER_PREFIX = "【内容】\n"
 
 
@@ -170,7 +122,9 @@ async def run(args):
         llm_call, model_id = create_openrouter_llm_call(model=args.model), args.model
         llm_call, model_id = create_openrouter_llm_call(model=args.model), args.model
     else:
     else:
         llm_call, model_id = build_eval_llm_call(args.model or DEFAULT_MODEL_CHOICE)
         llm_call, model_id = build_eval_llm_call(args.model or DEFAULT_MODEL_CHOICE)
-    print(f"🔧 工具解构 {len(todo)} 帖 · 模型 {model_id}")
+    # 版本号:本次解构的「月日时分」,本批所有帖共用一个版本(保留历史,多版本共存)
+    version = args.version or ("v_" + datetime.now().strftime("%m%d%H%M"))
+    print(f"🔧 工具解构 {len(todo)} 帖 · 模型 {model_id} · 版本 {version}")
 
 
     # 收配图(多模态)
     # 收配图(多模态)
     await _attach_image_refs(todo, MAX_IMAGES, max(2, args.max_concurrent * 2), "url")
     await _attach_image_refs(todo, MAX_IMAGES, max(2, args.max_concurrent * 2), "url")
@@ -187,14 +141,15 @@ async def run(args):
             "title": (s.get("post") or {}).get("title", ""),
             "title": (s.get("post") or {}).get("title", ""),
             "url": s.get("source_url", ""),
             "url": s.get("source_url", ""),
             "model": model_id,
             "model": model_id,
+            "version": version,
             "tool_count": len(tools),
             "tool_count": len(tools),
             "tools": tools,
             "tools": tools,
         }, ensure_ascii=False, indent=2), encoding="utf-8")
         }, ensure_ascii=False, indent=2), encoding="utf-8")
-        print(f"   ✅ {s['case_id']} → {len(tools)} 个工具 · ${cost:.4f} → {of.name}")
+        print(f"   ✅ {s['case_id']} → {len(tools)} 个工具 · ${cost:.4f} · 版本 {version} → {of.name}")
 
 
         # 双写 MySQL(失败不阻断,本地 json 已落盘)
         # 双写 MySQL(失败不阻断,本地 json 已落盘)
         if _db:
         if _db:
-            _db.upsert_tools(q, s["case_id"], model_id, tools,
+            _db.upsert_tools(q, s["case_id"], model_id, tools, version,
                              platform=s.get("platform"),
                              platform=s.get("platform"),
                              post_title=(s.get("post") or {}).get("title", ""))
                              post_title=(s.get("post") or {}).get("title", ""))
         return cost
         return cost
@@ -209,7 +164,8 @@ def main():
     p.add_argument("--case-ids", required=True, help="逗号分隔的 case_id 列表")
     p.add_argument("--case-ids", required=True, help="逗号分隔的 case_id 列表")
     p.add_argument("--model", default=None, help="模型(默认 gemini-3.1-flash-lite;可传 OpenRouter model id)")
     p.add_argument("--model", default=None, help="模型(默认 gemini-3.1-flash-lite;可传 OpenRouter model id)")
     p.add_argument("--max-concurrent", type=int, default=3)
     p.add_argument("--max-concurrent", type=int, default=3)
-    p.add_argument("--force", action="store_true", help="覆盖已解构的帖子")
+    p.add_argument("--force", action="store_true", help="覆盖已解构的帖子(生成新版本)")
+    p.add_argument("--version", default=None, help="指定版本号(默认自动用 v_月日时分)")
     args = p.parse_args()
     args = p.parse_args()
     asyncio.run(run(args))
     asyncio.run(run(args))