2
0

35 Коммиты 3bcdbafa0f ... ab0dba2572

Автор SHA1 Сообщение Дата
  刘文武 ab0dba2572 feat(mode_workflow): query 列表倒序显示(最新在上),默认选中最新 1 месяц назад
  刘文武 dd846b95f7 feat(mode_workflow): 自建确认弹框 uiConfirm 替换浏览器原生 confirm(搜全部达标/解构全部已采纳) 1 месяц назад
  刘文武 d286ecff6c feat(mode_workflow): 评估按钮评分中禁用+「评估中…」loading,防重复点击(showTask 加结束回调,失败也恢复) 1 месяц назад
  刘文武 ada64ace0f perf(mode_workflow): fetch_queries 改 SQL 标量算采纳(不拉整表 blob)+ server 缓存 query 列表(任务完成失效) 1 месяц назад
  刘文武 a14d0c8ae3 perf(mode_workflow): 帖列表瘦身——fetch_posts 不再拉 llm_evaluation/body(78MB→123KB),详情按需 /api/post 1 месяц назад
  刘文武 5979c7176d feat(mode_workflow): 达标格醒目高亮(琥珀填充+胶囊分数) + 改写词搜索/原组合词入 query 列表(--query-text) 1 месяц назад
  刘文武 86b10a195d fix(mode_workflow): _next_query_id 加内存预留,批量发起搜索不再重号(行未落库前) 1 месяц назад
  刘文武 a14c07351a feat(mode_workflow): 搜索进度可见——任务面板提到弹层之上 + 搜全部达标 实时列出各任务状态 1 месяц назад
  刘文武 e89b357795 docs(mode_workflow): README/手册 补 Query 规则组织器 + 端到端管线 收尾 1 месяц назад
  刘文武 3bb740096b feat(mode_workflow): Part C 帖列表加「解构全部已采纳」按钮(复用 startExtract,按当前 tab 方向) 1 месяц назад
  刘文武 9a386dbd99 feat(mode_workflow): Query 评分气泡标注综合分公式 + tier 独立成行(澄清口径) 1 месяц назад
  刘文武 295e5699a3 feat(mode_workflow): Query 规则 达标口径改为 keep 且 综合分≥阈值(默认7,弹层可调) 1 месяц назад
  刘文武 59ea74c270 feat(mode_workflow): Query 规则弹层 UI 重做(边界/布局/分组表头)+ 动作/类型 下钻筛选 + 点格评分气泡 1 месяц назад
  刘文武 21e6577a75 fix(mode_workflow): Query 规则 点格取词改用评分记录(去脆弱正则)+ 补分组表头样式 1 месяц назад
  刘文武 6ecf33eeec feat(mode_workflow): Query 规则 点格搜索 + 搜全部达标(run_search 方向无关) 1 месяц назад
  刘文武 81810d72e6 feat(mode_workflow): Query 规则 评估高亮(POST query_score + 轮询 + keep 高亮) 1 месяц назад
  刘文武 ce7f7eb75a feat(mode_workflow): Query 规则 动作×类型 27×50 正交表渲染(tier 底色) 1 месяц назад
  刘文武 4cbecbb5d6 feat(mode_workflow): Query 规则 维度 chips(扁平单选 + 实质/形式 接口下钻) 1 месяц назад
  刘文武 008bdbd6b1 feat(mode_workflow): Query 规则 顶栏按钮 + 弹层骨架 + 维度常量 1 месяц назад
  刘文武 8c74a90d8d docs(mode_workflow): Part A2 query-builder 前端 实现计划 1 месяц назад
  刘文武 eb612ece8c fix(mode_workflow): query_score 成本回退(token估)+ /api/query_score sel 正则守卫(补 import re) 1 месяц назад
  刘文武 277f75faba docs(mode_workflow): README 补 query_score 脚本/prompt 1 месяц назад
  刘文武 10e281dab8 feat(mode_workflow): /api/query_score 触发+取结果(选择哈希缓存,命中短路) 1 месяц назад
  刘文武 ff41136c4f feat(mode_workflow): GET /api/category_tree 拉取+缓存 实质/形式 分类树 1 месяц назад
  刘文武 99279e70b1 feat(mode_workflow): GET /api/query_matrix 返回内容树矩阵(ETag,模块级缓存) 1 месяц назад
  刘文武 0bb4664a90 feat(mode_workflow): 新增 query_score 评分脚本(tier≥1×维度→Sonnet,原子落盘缓存) 1 месяц назад
  刘文武 58a3304f2c feat(mode_workflow): 新增 query 评分 system prompt 1 месяц назад
  刘文武 df7c7f6f71 docs(mode_workflow): Part A1 query-score 后端 实现计划 1 месяц назад
  刘文武 99aec82daf fix(mode_workflow): route_tables 兜底覆盖未知标签(不丢帖)+README runs 目录订正 1 месяц назад
  刘文武 5cf7c3c2ed docs(mode_workflow): 搜索入库改为按知识类型标签路由双表 1 месяц назад
  刘文武 76d0ff30bd feat(mode_workflow): search_eval 统一评估+按知识类型路由双表,评估去重跨表 1 месяц назад
  刘文武 b91a440b33 feat(mode_workflow): 新增 fetch_existing_eval_any 跨表评估去重(评估与表无关) 1 месяц назад
  刘文武 cbf23d9351 feat(mode_workflow): 新增 route_tables 知识类型→落表路由(工序/能力→process,工具→tools) 1 месяц назад
  刘文武 b66774048f docs(mode_workflow): Part B 搜索路由 实现计划 1 месяц назад
  刘文武 11d2289de8 docs(mode_workflow): Query 规则组织器 + 正交评估高亮 + 端到端管线 设计 1 месяц назад

+ 505 - 0
docs/superpowers/plans/2026-06-18-partA1-query-score-backend.md

@@ -0,0 +1,505 @@
+# Part A1:query-builder 后端(矩阵/分类树/评分脚本/接口)Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 提供 query-builder 弹层所需的全部后端:返回内容树矩阵、拉取并缓存实质/形式分类树、用 Sonnet 对 tier≥1 格批量评分的脚本,以及触发/取结果的接口(带按选择哈希的缓存,省钱)。
+
+**Architecture:** 新增独立脚本 `stages/query_score.py`(异步并发分批评分,结果原子落盘 `.cache/query_score/<sel>.json`)+ prompt 文件。`server.py` 加 4 个路由:`GET /api/query_matrix`、`GET /api/category_tree`、`POST /api/query_score`(命中缓存短路,否则 `_spawn_task` 起脚本)、`GET /api/query_score?sel=`(读结果)。server 负责选择哈希与缓存判定,脚本只按 `--sel` 写对应缓存文件,二者不互相 import(避免把 agent 重栈拖进 server)。
+
+**Tech Stack:** Python 3 stdlib(`http.server`/`urllib`)+ 现有 `agent.llm.openrouter`。无 pytest——验证用 `python3 -m py_compile`、`python3 -c "import server; ..."`(手册已这么用,`import server` 无副作用)、脚本 `--dry-run`/`--limit`、`curl`(需手动重启 server 后)。
+
+参考 spec:`docs/superpowers/specs/2026-06-18-query-builder-design.md`(Part A / 评分脚本 / 评分 Prompt)。
+
+工作目录:所有命令在 `cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow` 下;提交在仓库根 `/Users/max_liu/max_liu/company/Agent`。分支 `dev-lxn`。
+
+---
+
+## File Structure
+
+- `examples/mode_workflow/prompts/query_score_system.md` — 评分 system prompt(可单独迭代)。
+- `examples/mode_workflow/stages/query_score.py` — 评分脚本(读矩阵→筛 tier≥1→拼词→分批 Sonnet→原子落盘)。
+- `examples/mode_workflow/server.py` — 加 `_matrix()`/`_category_tree()`/`_query_sel_hash()` 三个 helper + 4 个路由。
+
+---
+
+### Task 1: 评分 Prompt 文件
+
+**Files:**
+- Create: `examples/mode_workflow/prompts/query_score_system.md`
+
+- [ ] **Step 1: 写文件**
+
+```markdown
+你是「内容制作知识库」的检索词评审。我们要从小红书/公众号等公共平台搜到
+"怎么做某类内容"的工序/工具教程。用户会给你一批由内容树维度正交生成的候选 query
+(每批共享一段固定上下文)。
+
+【逐条打分,只看这条 query 本身】
+- natural(0-10): 像不像真人会在搜索框打的人话(越口语、越具体越高;生造词/学术腔越低)
+- findable(0-10): 公共域是否真有对口的"怎么做"内容能被搜到(可参考 tier,但以 query 实际可搜性为准)
+- useful(0-10): 命中的内容对"内容制作知识库"是否有价值(教程/方法/工具 高;成品展示/抽象概念 低)
+- keep(bool): 三者均衡后是否值得真的拿去搜(有意义=true)
+- rewrite: 若 keep 但措辞生硬,给一个更像人话的等价搜索词(否则原样返回该 query)
+- reason: 一句话理由(≤30字)
+
+严格只输出 JSON 数组,每个候选一项,idx 对应输入序号,不要任何额外文字/解释/markdown 围栏:
+[{"idx":0,"natural":8,"findable":7,"useful":9,"keep":true,"rewrite":"...","reason":"..."}]
+```
+
+- [ ] **Step 2: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/prompts/query_score_system.md
+git commit -m "feat(mode_workflow): 新增 query 评分 system prompt"
+```
+
+---
+
+### Task 2: 评分脚本 `stages/query_score.py`
+
+**Files:**
+- Create: `examples/mode_workflow/stages/query_score.py`
+
+- [ ] **Step 1: 写脚本**
+
+```python
+# -*- coding: utf-8 -*-
+"""Query 正交格评分 · 对 judged_matrix 的 tier≥1 格(动作×类型)在当前维度上下文下用 Sonnet 打分,
+挑出有意义、人话、有助于内容制作知识库目的的组合。结果原子写 .cache/query_score/<sel>.json。
+
+由 server.py /api/query_score 起子进程调;也可独立跑:
+  python stages/query_score.py --tool-type AI --modality 图片 --suffix 怎么做 \
+      --substance-path 表象,实体 --form-path 呈现,视觉 --sel adhoc --dry-run
+"""
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[3]   # …/Agent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from examples.process_pipeline.script.llm_helper import call_llm_with_retry
+
+HERE = Path(__file__).resolve().parent
+MW = HERE.parent
+MATRIX_FILE = MW / "reference" / "judged_matrix.json"
+PROMPT_FILE = MW / "prompts" / "query_score_system.md"
+CACHE_DIR = MW / ".cache" / "query_score"
+DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
+BATCH = 40
+CONCURRENCY = 5
+
+
+def _build_cells(matrix, tool_type, modality, suffix):
+    """筛 tier≥1 格,产出 [{a_idx,t_idx,action,type,tier,query}]。
+    query = [工具类型] 动作叶 类型叶 [模态] [后缀],"无"/空跳过。"""
+    actions, types, grid = matrix["actions"], matrix["types"], matrix["matrix"]
+    pre, mod, suf = (tool_type or "").strip(), (modality or "").strip(), (suffix or "").strip()
+    cells = []
+    for ai, arow in enumerate(grid):
+        action = actions[ai]["name"]
+        for ti, cell in enumerate(arow):
+            if cell.get("tier", 0) < 1:
+                continue
+            typ = types[ti]["name"]
+            parts = [p for p in (pre, action, typ, mod, suf) if p and p != "无"]
+            cells.append({"a_idx": ai, "t_idx": ti, "action": action,
+                          "type": typ, "tier": cell["tier"], "query": " ".join(parts)})
+    return cells
+
+
+def _build_user(batch, ctx):
+    lines = [f'{i}. "{c["query"]}"   (动作={c["action"]} 类型={c["type"]} 内容树tier={c["tier"]})'
+             for i, c in enumerate(batch)]
+    sub = (ctx["substance"] or "无").replace(",", "›")
+    form = (ctx["form"] or "无").replace(",", "›")
+    return (f"【固定上下文(本批共享)】\n"
+            f"工具类型: {ctx['tool_type'] or '无'}   模态: {ctx['modality'] or '无'}   后缀: {ctx['suffix'] or '无'}\n"
+            f"(实质/形式不参与拼词,仅供理解领域定位: 实质路径={sub}  形式路径={form})\n\n"
+            f"【候选列表】每条 = 动作 + 类型 + 上下文词拼成的 query:\n" + "\n".join(lines))
+
+
+def _validate(d):
+    return None if isinstance(d, list) else "需 JSON 数组"
+
+
+async def _score_batch(batch, ctx, system, llm_call, model, sem):
+    messages = [{"role": "system", "content": system},
+                {"role": "user", "content": _build_user(batch, ctx)}]
+    async with sem:
+        data, cost = await call_llm_with_retry(
+            llm_call=llm_call, messages=messages, model=model,
+            temperature=0.1, max_tokens=4000, validate_fn=_validate,
+            task_name=f"QueryScore[{batch[0]['query'][:12]}]")
+    out = {}
+    for v in (data or []):
+        if not isinstance(v, dict):
+            continue
+        i = v.get("idx")
+        if not isinstance(i, int) or not (0 <= i < len(batch)):
+            continue
+        c = batch[i]
+        try:
+            score = round(float(v.get("natural")) * 0.4 + float(v.get("findable")) * 0.3
+                          + float(v.get("useful")) * 0.3, 1)
+        except (TypeError, ValueError):
+            score = None
+        out[f"{c['a_idx']}_{c['t_idx']}"] = {
+            "query": c["query"], "natural": v.get("natural"), "findable": v.get("findable"),
+            "useful": v.get("useful"), "keep": bool(v.get("keep")),
+            "rewrite": (v.get("rewrite") or c["query"]), "reason": v.get("reason", ""),
+            "score": score}
+    return out, cost
+
+
+async def run(args):
+    matrix = json.loads(MATRIX_FILE.read_text(encoding="utf-8"))
+    ctx = {"tool_type": args.tool_type, "modality": args.modality, "suffix": args.suffix,
+           "substance": args.substance_path, "form": args.form_path}
+    cells = _build_cells(matrix, args.tool_type, args.modality, args.suffix)
+    if args.limit:
+        cells = cells[:args.limit]
+    print(f"📋 tier≥1 候选 {len(cells)} 格" + (f" (--limit {args.limit})" if args.limit else ""))
+    if args.dry_run:
+        for c in cells[:10]:
+            print(f"  [{c['tier']}] {c['query']}")
+        print(f"…共 {len(cells)} 格(dry-run,未调 LLM)")
+        return 0
+
+    system = PROMPT_FILE.read_text(encoding="utf-8")
+    from agent.llm.openrouter import create_openrouter_llm_call
+    llm_call = create_openrouter_llm_call(model=args.model)
+    sem = asyncio.Semaphore(CONCURRENCY)
+    batches = [cells[i:i + BATCH] for i in range(0, len(cells), BATCH)]
+    print(f"🤖 {len(batches)} 批 × ≤{BATCH} 格 · 并发 {CONCURRENCY} · 模型 {args.model}")
+    results = await asyncio.gather(*[
+        _score_batch(b, ctx, system, llm_call, args.model, sem) for b in batches])
+    merged, cost = {}, 0.0
+    for cmap, c in results:
+        merged.update(cmap)
+        cost += c
+    kept = sum(1 for v in merged.values() if v.get("keep"))
+    out = {"sel": ctx, "model": args.model, "kept": kept, "total": len(merged),
+           "cost_usd": round(cost, 4), "cells": merged}
+    CACHE_DIR.mkdir(parents=True, exist_ok=True)
+    dest = CACHE_DIR / f"{args.sel}.json"
+    tmp = dest.with_suffix(".tmp")
+    tmp.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
+    tmp.replace(dest)   # 原子落盘,避免前端读到半截
+    print(f"✅ 评分完成 {len(merged)} 格 · keep {kept} · ${cost:.4f} → {dest.name}")
+    return 0
+
+
+def main():
+    p = argparse.ArgumentParser(description="Query 正交格评分(tier≥1 × 当前维度 → Sonnet 打分)")
+    p.add_argument("--tool-type", default="")
+    p.add_argument("--modality", default="")
+    p.add_argument("--suffix", default="")
+    p.add_argument("--substance-path", default="", help="实质祖先路径,逗号分隔(仅作上下文)")
+    p.add_argument("--form-path", default="", help="形式祖先路径,逗号分隔(仅作上下文)")
+    p.add_argument("--model", default=DEFAULT_MODEL)
+    p.add_argument("--sel", default="adhoc", help="缓存文件名(server 传 sel_hash)")
+    p.add_argument("--limit", type=int, default=None, help="只评前 N 格(调试)")
+    p.add_argument("--dry-run", action="store_true", help="只拼词打印,不调 LLM、不落盘")
+    p.add_argument("--force", action="store_true", help="(占位)缓存短路在 server 侧,本脚本恒重算")
+    args = p.parse_args()
+    raise SystemExit(asyncio.run(run(args)))
+
+
+if __name__ == "__main__":
+    main()
+```
+
+- [ ] **Step 2: dry-run 验证拼词与筛选(不花钱)**
+
+Run:
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 stages/query_score.py --tool-type AI --modality 图片 --suffix 怎么做 --dry-run
+```
+Expected: 打印 `📋 tier≥1 候选 643 格`,随后 10 行形如 `[3] AI 检索 提示词 图片 怎么做`,末行 `…共 643 格(dry-run,未调 LLM)`。
+
+- [ ] **Step 3: 小样真评(~$0.01,验证 JSON 结构与落盘)**
+
+Run:
+```bash
+python3 stages/query_score.py --tool-type AI --suffix 怎么做 --sel _smoketest --limit 8
+python3 - <<'PY'
+import json, pathlib
+p = pathlib.Path(".cache/query_score/_smoketest.json")
+d = json.loads(p.read_text(encoding="utf-8"))
+assert d["total"] >= 1 and "cells" in d
+k, v = next(iter(d["cells"].items()))
+assert set(["query","natural","findable","useful","keep","rewrite","reason","score"]) <= set(v)
+assert "_" in k  # 键形如 a_idx_t_idx
+print("✔ 评分结果结构 OK:", d["kept"], "/", d["total"], "keep, $", d["cost_usd"])
+PY
+rm -f .cache/query_score/_smoketest.json
+```
+Expected: `✔ 评分结果结构 OK: ...`
+
+- [ ] **Step 4: 编译 + 提交**
+
+```bash
+python3 -m py_compile stages/query_score.py && echo OK
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/stages/query_score.py
+git commit -m "feat(mode_workflow): 新增 query_score 评分脚本(tier≥1×维度→Sonnet,原子落盘缓存)"
+```
+
+---
+
+### Task 3: server `_matrix()` + `GET /api/query_matrix`
+
+**Files:**
+- Modify: `examples/mode_workflow/server.py`(在 `MATRIX_FILE = ...` 行之后加 helper;在 `do_GET` 的 `elif u.path == "/api/dashboard"` 之前加路由)
+
+- [ ] **Step 1: 加矩阵缓存 helper**
+
+在 `server.py` 的 `MATRIX_FILE = HERE / "reference" / "judged_matrix.json"` 行之后插入:
+
+```python
+_MATRIX_CACHE = None
+
+
+def _matrix():
+    """judged_matrix.json 解析后模块级缓存(只读,进程内不变)。"""
+    global _MATRIX_CACHE
+    if _MATRIX_CACHE is None:
+        _MATRIX_CACHE = json.loads(MATRIX_FILE.read_text(encoding="utf-8"))
+    return _MATRIX_CACHE
+```
+
+- [ ] **Step 2: 加路由**
+
+在 `do_GET` 里 `elif u.path == "/api/dashboard":` 这一行之前插入:
+
+```python
+            elif u.path == "/api/query_matrix":
+                self._json_etag(_matrix())
+```
+
+- [ ] **Step 3: 验证(import 无副作用,直接调 helper)**
+
+Run:
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 -c "import server; m=server._matrix(); print('✔', len(m['actions']),'动作', len(m['types']),'类型', len(m['matrix']),'行')"
+python3 -m py_compile server.py && echo OK
+```
+Expected: `✔ 27 动作 50 类型 27 行` 和 `OK`
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/server.py
+git commit -m "feat(mode_workflow): GET /api/query_matrix 返回内容树矩阵(ETag,模块级缓存)"
+```
+
+---
+
+### Task 4: server `_category_tree()` + `GET /api/category_tree`
+
+**Files:**
+- Modify: `examples/mode_workflow/server.py`(顶部加 `import urllib.request, urllib.parse`;`_matrix` 之后加 helper;`do_GET` 加路由)
+
+- [ ] **Step 1: 顶部加 import**
+
+在 `server.py` 现有 import 区(与其它 `import` 同处)加:
+
+```python
+import urllib.request
+import urllib.parse
+```
+
+- [ ] **Step 2: 加 helper(紧跟 `_matrix` 之后)**
+
+```python
+CATEGORY_API = "https://library.aiddit.com/api/pattern/executions/401/category-tree"
+CAT_CACHE_DIR = HERE / ".cache" / "category_tree"
+
+
+def _category_tree(source_type):
+    """拉取并缓存 实质/形式 分类树(library.aiddit.com)。缓存命中直接读盘;
+    上游不可达则抛异常(do_GET 转 500,前端降级)。"""
+    if source_type not in ("实质", "形式"):
+        raise ValueError("source_type 只能是 实质/形式")
+    CAT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+    cache = CAT_CACHE_DIR / f"{source_type}.json"
+    if cache.is_file():
+        return json.loads(cache.read_text(encoding="utf-8"))
+    url = CATEGORY_API + "?" + urllib.parse.urlencode({"source_type": source_type})
+    req = urllib.request.Request(url, headers={"Accept": "application/json"})
+    with urllib.request.urlopen(req, timeout=20) as resp:
+        data = json.loads(resp.read().decode("utf-8"))
+    tmp = cache.with_suffix(".tmp")
+    tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+    tmp.replace(cache)
+    return data
+```
+
+- [ ] **Step 3: 加路由(`do_GET` 的 `elif u.path == "/api/query_matrix":` 之后)**
+
+```python
+            elif u.path == "/api/category_tree":
+                self._json_etag(_category_tree(qs.get("source_type", "实质")))
+```
+
+- [ ] **Step 4: 验证(真拉一次 + 缓存命中)**
+
+Run:
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 -c "import server; d=server._category_tree('实质'); print('✔ tree 顶层', len(d['tree']), '节点; 首个=', d['tree'][0]['name'])"
+test -f .cache/category_tree/实质.json && echo '✔ 已缓存落盘'
+python3 -m py_compile server.py && echo OK
+```
+Expected: `✔ tree 顶层 N 节点; 首个= 表象`、`✔ 已缓存落盘`、`OK`
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/server.py
+git commit -m "feat(mode_workflow): GET /api/category_tree 拉取+缓存 实质/形式 分类树"
+```
+
+---
+
+### Task 5: server `_query_sel_hash()` + `POST/GET /api/query_score`
+
+**Files:**
+- Modify: `examples/mode_workflow/server.py`(`_category_tree` 之后加 helper;`do_GET` 加 GET 路由;`do_POST` 加 POST 路由)
+
+- [ ] **Step 1: 加选择哈希 helper**
+
+紧跟 `_category_tree` 之后插入(`hashlib` 已在 server.py 顶部 import):
+
+```python
+SCORE_CACHE_DIR = HERE / ".cache" / "query_score"
+
+
+def _query_sel_hash(sel):
+    """对评分选择 + 矩阵指纹算稳定短哈希,作为缓存文件名(同选择不重复付费)。"""
+    matrix_sig = hashlib.md5(MATRIX_FILE.read_bytes()).hexdigest()[:8]
+    canon = json.dumps([sel, matrix_sig], ensure_ascii=False, sort_keys=True)
+    return hashlib.md5(canon.encode("utf-8")).hexdigest()[:16]
+```
+
+- [ ] **Step 2: 加 GET 路由(`do_GET` 的 `elif u.path == "/api/category_tree":` 之后)**
+
+```python
+            elif u.path == "/api/query_score":
+                cache = SCORE_CACHE_DIR / f"{qs.get('sel', '')}.json"
+                if cache.is_file():
+                    self._json_etag(json.loads(cache.read_text(encoding="utf-8")))
+                else:
+                    self._json({"pending": True}, 202)
+```
+
+- [ ] **Step 3: 加 POST 路由(`do_POST` 的 `elif u.path == "/api/run_search":` 之前)**
+
+```python
+            elif u.path == "/api/query_score":
+                sel = {
+                    "tool_type": payload.get("tool_type", ""),
+                    "modality": payload.get("modality", ""),
+                    "suffix": payload.get("suffix", ""),
+                    "substance_path": payload.get("substance_path") or [],
+                    "form_path": payload.get("form_path") or [],
+                    "model": payload.get("model", "anthropic/claude-sonnet-4-6"),
+                }
+                sel_hash = _query_sel_hash(sel)
+                cache = SCORE_CACHE_DIR / f"{sel_hash}.json"
+                if cache.is_file() and not payload.get("force"):
+                    return self._json({"sel": sel_hash, "cached": True})
+                cmd = [sys.executable, "stages/query_score.py", "--sel", sel_hash,
+                       "--tool-type", sel["tool_type"], "--modality", sel["modality"],
+                       "--suffix", sel["suffix"],
+                       "--substance-path", ",".join(sel["substance_path"]),
+                       "--form-path", ",".join(sel["form_path"]),
+                       "--model", sel["model"]]
+                if payload.get("force"):
+                    cmd += ["--force"]
+                self._json({"sel": sel_hash, "task_id": _spawn_task("score", cmd), "cached": False})
+```
+
+- [ ] **Step 4: 验证哈希稳定性 + 编译**
+
+Run:
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 -c "
+import server
+s = {'tool_type':'AI','modality':'图片','suffix':'怎么做','substance_path':['表象'],'form_path':[],'model':'anthropic/claude-sonnet-4-6'}
+h1 = server._query_sel_hash(s); h2 = server._query_sel_hash(dict(s))
+assert h1 == h2 and len(h1) == 16, '哈希应稳定且 16 位'
+s2 = dict(s); s2['modality'] = '视频'
+assert server._query_sel_hash(s2) != h1, '选择变了哈希应变'
+print('✔ sel_hash 稳定且区分:', h1)
+"
+python3 -m py_compile server.py && echo OK
+```
+Expected: `✔ sel_hash 稳定且区分: ...`、`OK`
+
+- [ ] **Step 5: 端到端冒烟(手动重启 server 后 curl)**
+
+> server.py 不自动重载,需先重启:若已在跑,`kill $(cat .cloudflared.pid 2>/dev/null; pgrep -f "python.*server.py")` 后 `python3 server.py &`(或用你平时启动 server 的方式)。仅本步需要 server 在跑。
+
+Run:
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+curl -s "http://localhost:8772/api/query_matrix" | python3 -c "import sys,json; print('matrix actions=', len(json.load(sys.stdin)['actions']))"
+curl -s "http://localhost:8772/api/category_tree?source_type=形式" | python3 -c "import sys,json; print('form tree top=', len(json.load(sys.stdin)['tree']))"
+curl -s -X POST "http://localhost:8772/api/query_score" -H 'Content-Type: application/json' \
+  -d '{"tool_type":"AI","suffix":"怎么做"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print('score POST ->', d)"
+```
+Expected: `matrix actions= 27`、`form tree top= N`、`score POST -> {'sel': ..., 'task_id'/'cached': ...}`。
+若返回 `task_id`,稍候 `curl "http://localhost:8772/api/query_score?sel=<上面的sel>"` 应从 202 `{"pending":true}` 变为完整结果。
+
+- [ ] **Step 6: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/server.py
+git commit -m "feat(mode_workflow): /api/query_score 触发+取结果(选择哈希缓存,命中短路)"
+```
+
+---
+
+### Task 6: 文档(README 结构表 + 接口说明)
+
+**Files:**
+- Modify: `examples/mode_workflow/README.md`
+
+- [ ] **Step 1: 结构表加两行**
+
+在 README「结构」表里 `reference/judged_matrix.json` 行之后加:
+
+```
+| `stages/query_score.py` | Query 正交格评分:tier≥1 格 × 维度上下文 → Sonnet 打分,结果缓存 `.cache/query_score/` |
+| `prompts/query_score_system.md` | 评分 system prompt(可单独迭代) |
+```
+
+- [ ] **Step 2: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/README.md
+git commit -m "docs(mode_workflow): README 补 query_score 脚本/prompt"
+```
+
+---
+
+## Self-Review
+
+- **Spec coverage(Part A 后端)**:`/api/query_matrix` → Task 3 ✓;`/api/category_tree`(拉+缓存+降级)→ Task 4 ✓;`stages/query_score.py`(tier≥1 筛选、拼词、分批并发、原子落盘、`--dry-run`/`--limit`)→ Task 2 ✓;评分 Prompt(natural/findable/useful/keep/rewrite/reason + JSON 数组)→ Task 1 ✓;综合分 `natural*0.4+findable*0.3+useful*0.3` → Task 2 `_score_batch` ✓;`/api/query_score` POST 触发+缓存短路、GET 取结果、`sel_hash`(选择+matrix_sig) → Task 5 ✓;cells 键 `<a_idx>_<t_idx>` → Task 2 ✓。
+- **Placeholder scan**:无 TBD/TODO;每步含完整代码与可运行验证。`--force` 在脚本里是占位参数(缓存短路由 server 负责),已在 help 注明,非占位逻辑缺口。
+- **Type consistency**:脚本输出 `cells[key]` 字段集与 spec/前端约定一致(query/natural/findable/useful/keep/rewrite/reason/score);server `_query_sel_hash(sel)` 入参为 dict、`sel` 字段集与脚本 CLI 参数一一对应(tool_type/modality/suffix/substance_path/form_path/model);GET/POST 同路径 `/api/query_score` 分属 do_GET/do_POST,不冲突。

+ 426 - 0
docs/superpowers/plans/2026-06-18-partA2-query-builder-frontend.md

@@ -0,0 +1,426 @@
+# Part A2:query-builder 前端弹层 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在工作台顶栏「新建搜索」旁加「Query 规则」按钮,打开弹层:上半用维度 chips 设上下文(工具类型/模态/后缀 扁平单选 + 实质/形式 任意深度下钻),下半渲染 `动作×类型` 整张 27×50 正交表(tier 底色),点「评估高亮」用 Sonnet 给 tier≥1 格打分并高亮 `keep` 格,点单格或「搜全部达标」直接发起搜索。
+
+**Architecture:** 纯前端,全部加进单文件 `index.html`。复用现有 helper:`$("#id")`、`api(path,opt)`、`toast()`、`showTask(title,taskId,onDone)`(轮询 `/api/task_status`,完成回调)。后端接口(A1 已就绪):`GET /api/query_matrix`、`GET /api/category_tree?source_type=`、`POST /api/query_score`(返回 `{sel,task_id,cached}`)、`GET /api/query_score?sel=`(结果或 202)、`POST /api/run_search`(复用,默认 xhs,gzh×20、不传 mode_type)。
+
+**Tech Stack:** 原生 JS/HTML/CSS(同 index.html 现状,无框架)。无自动化前端测试——验证 = `node --check`(抽出的 script 语法)/ 浏览器手测(用户)。
+
+参考 spec:`docs/superpowers/specs/2026-06-18-query-builder-design.md`(Part A 前端、评分 Prompt、Image #1/#2 视觉)。
+
+**重要约定(已与用户确认):** `动作/类型` **只作正交表两轴,不做成 chips**。chips 仅 5 个维度:工具类型/模态/后缀(扁平单选)、实质/形式(下钻)。
+
+工作目录命令在 `examples/mode_workflow/`,提交在仓库根,分支 `dev-lxn`。
+
+---
+
+## 实现前必读(现有模式,照抄风格)
+
+实现者先在 `index.html` 里读这些锚点,照其风格写新代码:
+- `const api = (p, opt) =>` (~2437):`await api("/api/x")` GET;`await api("/api/x",{method:"POST",body:JSON.stringify(b)})`。失败抛异常,`e.body?.error`/`e.status` 可取。
+- `function toast(msg, type, ms)` (~2422):`toast("...", "warn"|"error"|"info")`。
+- `function showTask(title, taskId, onDone)` (~3581):弹任务面板 + 轮询 `/api/task_status`,`done` 时调 `onDone()`。
+- `$("#btn-new-search").onclick` / `$("#s-go").onclick` (~3659):新建搜索 modal 开关 + 组 body + `POST /api/run_search` + `showTask`。**镜像它**。
+- `#search-modal` 的 HTML(`grep -n 'id="search-modal"' index.html`,~2347):modal 结构/类(`.modal-bg`/`.modal`)参照它。
+- 事件绑定集中在底部 `<script>` 的初始化区(`$("#m-process").onclick = ...` 一带,~3699)。新绑定加这里。
+
+---
+
+### Task 1: 顶栏按钮 + 弹层骨架 + 维度常量
+
+**Files:**
+- Modify: `examples/mode_workflow/index.html`
+
+- [ ] **Step 1: 顶栏加按钮**
+
+在 `<button class="btn seal" id="btn-new-search"> + 新建搜索 </button>` 之后紧挨着加:
+
+```html
+        <button class="btn" id="btn-query-rule" style="margin-left:8px">Query 规则</button>
+```
+
+- [ ] **Step 2: 加弹层 HTML(放在 `#search-modal` 那段 modal 之后)**
+
+```html
+    <!-- Query 规则组织器 弹层 -->
+    <div class="modal-bg" id="qr-modal" hidden>
+      <div class="modal" style="max-width:1180px;width:96vw;max-height:92vh;display:flex;flex-direction:column">
+        <h2>Query 词组织器</h2>
+        <div id="qr-dims" style="margin:6px 0 10px"></div>
+        <div style="display:flex;gap:8px;align-items:center;margin:6px 0 10px">
+          <button class="btn seal" id="qr-score">生成正交表 &amp; 评估高亮</button>
+          <button class="btn" id="qr-search-all" hidden>搜全部达标</button>
+          <span id="qr-hint" style="color:#888;font-size:12px"></span>
+          <div style="flex:1"></div>
+          <button class="btn" id="qr-close">关闭</button>
+        </div>
+        <div id="qr-table-wrap" style="overflow:auto;flex:1;border:1px solid #eee;border-radius:8px"></div>
+      </div>
+    </div>
+```
+
+- [ ] **Step 3: 加维度常量 + 状态(放在底部 `<script>` 内、初始化区之上)**
+
+```javascript
+      /* ════ Query 规则组织器 ════ */
+      // 扁平维度词表(移植自 query-builder.html DIMS);实质/形式 改为接口下钻,不在此。
+      const QR_FLAT = [
+        { id: "tool_type", label: "工具类型", items: ["AI", "桌面 APP", "云端 Web", "API·CLI", "插件扩展"] },
+        { id: "modality", label: "模态", items: ["图片", "视频"] },
+        { id: "suffix", label: "后缀", items: ["怎么做"] },
+      ];
+      const QR_TREE = [
+        { id: "substance", label: "实质", source: "实质" },
+        { id: "form", label: "形式", source: "形式" },
+      ];
+      const qrState = {
+        flat: { tool_type: null, modality: null, suffix: null },
+        treePath: { substance: [], form: [] },   // 选中节点的祖先+自身 name 路径
+        treeData: { substance: null, form: null }, // 接口返回的 {tree:[...]}
+        matrix: null,                              // /api/query_matrix
+        scores: null,                              // 最近一次评分结果 cells
+      };
+```
+
+- [ ] **Step 4: 开关弹层绑定(初始化区,照 search-modal 写法)**
+
+```javascript
+      $("#btn-query-rule").onclick = () => { $("#qr-modal").hidden = false; qrOpen(); };
+      $("#qr-close").onclick = () => { $("#qr-modal").hidden = true; };
+      $("#qr-modal").onclick = (e) => { if (e.target === $("#qr-modal")) $("#qr-modal").hidden = true; };
+```
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/index.html
+git commit -m "feat(mode_workflow): Query 规则 顶栏按钮 + 弹层骨架 + 维度常量"
+```
+
+---
+
+### Task 2: 维度 chips(扁平单选 + 实质/形式 下钻)
+
+**Files:**
+- Modify: `examples/mode_workflow/index.html`
+
+- [ ] **Step 1: 加 chips 渲染 + 打开时拉数据**
+
+在 Query 规则 script 区加:
+
+```javascript
+      function qrChip(label, active, on) {
+        const b = document.createElement("button");
+        b.className = "chip" + (active ? " on" : "");
+        b.textContent = label;
+        b.onclick = on;
+        return b;
+      }
+      function renderQrDims() {
+        const root = $("#qr-dims");
+        root.innerHTML = "";
+        // 扁平维度
+        QR_FLAT.forEach((d) => {
+          const row = document.createElement("div");
+          row.className = "qr-row";
+          row.innerHTML = `<span class="qr-lab">· ${d.label}</span>`;
+          const wrap = document.createElement("span");
+          wrap.className = "qr-chips";
+          wrap.appendChild(qrChip("无", qrState.flat[d.id] == null, () => { qrState.flat[d.id] = null; renderQrDims(); }));
+          d.items.forEach((it) =>
+            wrap.appendChild(qrChip(it, qrState.flat[d.id] === it, () => { qrState.flat[d.id] = it; renderQrDims(); })));
+          row.appendChild(wrap);
+          root.appendChild(row);
+        });
+        // 实质/形式 下钻:每一层一行,展示当前层可选项;选中后再展开下一层
+        QR_TREE.forEach((d) => {
+          const data = qrState.treeData[d.id];
+          const path = qrState.treePath[d.id];
+          // 沿 path 定位到当前层的 children 列表(逐层)
+          let level = data ? data.tree : [];
+          const rows = [];
+          for (let depth = 0; ; depth++) {
+            const labtxt = depth === 0 ? `· ${d.label}` : "";
+            const row = document.createElement("div");
+            row.className = "qr-row";
+            row.innerHTML = `<span class="qr-lab">${labtxt}</span>`;
+            const wrap = document.createElement("span");
+            wrap.className = "qr-chips";
+            wrap.appendChild(qrChip("无", path.length === depth, () => { qrState.treePath[d.id] = path.slice(0, depth); renderQrDims(); }));
+            (level || []).forEach((node) =>
+              wrap.appendChild(qrChip(node.name, path[depth] === node.name,
+                () => { qrState.treePath[d.id] = [...path.slice(0, depth), node.name]; renderQrDims(); })));
+            row.appendChild(wrap);
+            rows.push(row);
+            // 进入已选中节点的下一层
+            const sel = (level || []).find((n) => n.name === path[depth]);
+            if (!sel || !(sel.children && sel.children.length)) break;
+            level = sel.children;
+          }
+          rows.forEach((r) => root.appendChild(r));
+        });
+      }
+      async function qrOpen() {
+        renderQrDims();
+        // 并行拉矩阵 + 两棵分类树(接口挂则该维度降级:提示不可选)
+        try { qrState.matrix = qrState.matrix || await api("/api/query_matrix"); }
+        catch (e) { return toast("内容树矩阵加载失败", "error"); }
+        for (const d of QR_TREE) {
+          if (qrState.treeData[d.id]) continue;
+          try { qrState.treeData[d.id] = await api("/api/category_tree?source_type=" + encodeURIComponent(d.source)); }
+          catch (e) { toast(`${d.label} 接口不可达,该维度暂不可选`, "warn"); }
+        }
+        renderQrDims();
+        renderQrTable();
+      }
+```
+
+- [ ] **Step 2: 加 chips/行的样式(放在页面 `<style>` 内,紧凑即可)**
+
+```css
+      .qr-row { display:flex; align-items:flex-start; gap:10px; padding:5px 0; border-bottom:1px solid #f4f4f4; }
+      .qr-lab { min-width:64px; color:#999; font-size:12.5px; padding-top:6px; flex-shrink:0; }
+      .qr-chips { display:flex; flex-wrap:wrap; gap:7px; flex:1; }
+      .chip { padding:4px 13px; border-radius:18px; font-size:12.5px; cursor:pointer; border:1.5px solid #e2e2e2; background:#fff; color:#666; }
+      .chip:hover { border-color:#bbb; color:#333; }
+      .chip.on { background:#1a1a1a; border-color:#1a1a1a; color:#fff; }
+```
+
+> 若 `.chip` 类已存在(被别处用),复用即可、勿重复定义;只补 `.qr-row/.qr-lab/.qr-chips`。
+
+- [ ] **Step 3: 浏览器手测占位(语法检查)**
+
+Run(抽出 `<script>` 做 JS 语法检查,需 node):
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 - <<'PY'
+import re, pathlib, subprocess, tempfile, os
+html = pathlib.Path("index.html").read_text(encoding="utf-8")
+scripts = re.findall(r"<script>(.*?)</script>", html, re.S)
+src = "\n".join(scripts)
+f = tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8"); f.write(src); f.close()
+r = subprocess.run(["node", "--check", f.name], capture_output=True, text=True)
+os.unlink(f.name)
+print("✔ JS 语法 OK" if r.returncode == 0 else "✗ 语法错:\n" + r.stderr)
+PY
+```
+Expected: `✔ JS 语法 OK`(若无 node 可跳过,记为手测项)。
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/index.html
+git commit -m "feat(mode_workflow): Query 规则 维度 chips(扁平单选 + 实质/形式 接口下钻)"
+```
+
+---
+
+### Task 3: 动作×类型 正交表渲染
+
+**Files:**
+- Modify: `examples/mode_workflow/index.html`
+
+- [ ] **Step 1: 加拼词 + 建表函数**
+
+```javascript
+      // 单格 query = [工具类型] 动作叶 类型叶 [模态] [后缀](与后端 query_score._build_cells 一致)
+      function qrCellQuery(action, type) {
+        const f = qrState.flat;
+        return [f.tool_type, action, type, f.modality, f.suffix].filter((x) => x && x !== "无").join(" ");
+      }
+      function renderQrTable() {
+        const m = qrState.matrix;
+        if (!m) return;
+        const { actions, types, matrix } = m;
+        const sc = qrState.scores;   // {"ai_ti":{keep,score,rewrite,...}}
+        // 表头:动作按 l1 分组(参考 Image #2,列头两行:l1 跨列 + 叶子)
+        let html = '<table class="qr-tab"><thead>';
+        // l1 行
+        html += '<tr><th class="qr-corner" rowspan="2">类型 \\ 动作</th>';
+        const groups = [];
+        actions.forEach((a, ai) => {
+          const last = groups[groups.length - 1];
+          if (last && last.l1 === a.l1) last.span++;
+          else groups.push({ l1: a.l1, span: 1, start: ai });
+        });
+        groups.forEach((g) => { html += `<th colspan="${g.span}" class="qr-gh">${g.l1}</th>`; });
+        html += "</tr><tr>";
+        actions.forEach((a) => { html += `<th class="qr-ah">${a.name}</th>`; });
+        html += "</tr></thead><tbody>";
+        // 行:类型按 l1 分组,组首插一行组标题
+        let curL1 = null;
+        types.forEach((t, ti) => {
+          if (t.l1 !== curL1) {
+            curL1 = t.l1;
+            html += `<tr><td class="qr-tl1" colspan="${actions.length + 1}">${t.l1}</td></tr>`;
+          }
+          html += `<tr><th class="qr-th">${t.name}</th>`;
+          actions.forEach((a, ai) => {
+            const cell = matrix[ai][ti] || {};
+            const tier = cell.tier || 0;
+            const q = qrCellQuery(a.name, t.name);
+            const v = sc ? sc[`${ai}_${ti}`] : null;
+            const keep = v && v.keep;
+            const cls = `qr-c t${tier}` + (tier === 0 ? " dead" : "") + (keep ? " keep" : "");
+            const badge = keep && v.score != null ? `<i class="qr-bd">${v.score}</i>` : "";
+            const title = v ? `${v.reason || ""} (n${v.natural}/f${v.findable}/u${v.useful})` : `tier ${tier}`;
+            html += `<td class="${cls}" data-ai="${ai}" data-ti="${ti}" title="${title.replace(/"/g, "&quot;")}">`
+                  + `${q}${badge}</td>`;
+          });
+          html += "</tr>";
+        });
+        html += "</tbody></table>";
+        $("#qr-table-wrap").innerHTML = html;
+      }
+```
+
+- [ ] **Step 2: 加表格样式**
+
+```css
+      .qr-tab { border-collapse:collapse; font-size:11px; }
+      .qr-tab th, .qr-tab td { border:1px solid #eee; padding:3px 6px; white-space:nowrap; }
+      .qr-tab thead th { position:sticky; top:0; background:#f7f7f7; z-index:2; }
+      .qr-corner { position:sticky; left:0; z-index:3; background:#f0f0f0; }
+      .qr-th { position:sticky; left:0; background:#fafafa; z-index:1; text-align:left; }
+      .qr-tl1 { background:#f3f4f6; font-weight:600; color:#444; }
+      .qr-c.t0 { background:#fafafa; color:#bbb; } .qr-c.t1 { background:#f1f8f1; }
+      .qr-c.t2 { background:#dcecdc; } .qr-c.t3 { background:#bfe0bf; }
+      .qr-c.dead { color:#ccc; } .qr-c.keep { outline:2px solid #2563eb; outline-offset:-2px; cursor:pointer; font-weight:600; }
+      .qr-bd { display:inline-block; margin-left:4px; font-style:normal; font-size:9px; color:#2563eb; }
+```
+
+- [ ] **Step 3: 语法检查 + 提交**
+
+Run: 重跑 Task 2 Step 3 的 `node --check` 片段 → `✔ JS 语法 OK`。
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/index.html
+git commit -m "feat(mode_workflow): Query 规则 动作×类型 27×50 正交表渲染(tier 底色)"
+```
+
+---
+
+### Task 4: 评估高亮(POST query_score + 轮询 + 渲染)
+
+**Files:**
+- Modify: `examples/mode_workflow/index.html`
+
+- [ ] **Step 1: 绑定「生成正交表 & 评估高亮」**
+
+```javascript
+      function qrSelBody() {
+        return {
+          tool_type: qrState.flat.tool_type || "",
+          modality: qrState.flat.modality || "",
+          suffix: qrState.flat.suffix || "",
+          substance_path: qrState.treePath.substance,
+          form_path: qrState.treePath.form,
+        };
+      }
+      async function qrLoadScores(sel) {
+        const r = await api("/api/query_score?sel=" + encodeURIComponent(sel));
+        if (r && r.pending) return false;
+        qrState.scores = r.cells || {};
+        renderQrTable();
+        const kept = Object.values(qrState.scores).filter((v) => v.keep).length;
+        $("#qr-hint").textContent = `评估完成:keep ${kept} 格 · $${r.cost_usd ?? "?"}`;
+        $("#qr-search-all").hidden = kept === 0;
+        return true;
+      }
+      $("#qr-score").onclick = async () => {
+        renderQrTable();   // 先按当前 chips 刷新拼词
+        $("#qr-hint").textContent = "提交评分…";
+        let r;
+        try { r = await api("/api/query_score", { method: "POST", body: JSON.stringify(qrSelBody()) }); }
+        catch (e) { return toast("评分启动失败:" + (e.body?.error || e.status), "error"); }
+        if (r.cached) { await qrLoadScores(r.sel); return; }
+        showTask("Query 评分 · 643 格", r.task_id, async () => { await qrLoadScores(r.sel); });
+      };
+```
+
+- [ ] **Step 2: 语法检查 + 提交**
+
+Run: `node --check` 片段 → `✔ JS 语法 OK`。
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/index.html
+git commit -m "feat(mode_workflow): Query 规则 评估高亮(POST query_score + 轮询 + keep 高亮)"
+```
+
+---
+
+### Task 5: 发起搜索(点单格 + 搜全部达标)
+
+**Files:**
+- Modify: `examples/mode_workflow/index.html`
+
+- [ ] **Step 1: 点高亮格搜 + 批量搜(事件委托 + 按钮)**
+
+```javascript
+      async function qrRunSearch(query) {
+        const body = { query, platforms: "xhs,gzh", max_count: 20 };   // 方向无关,标签自动路由
+        const r = await api("/api/run_search", { method: "POST", body: JSON.stringify(body) });
+        return r;   // {task_id, query_id}
+      }
+      // 点高亮格 → 确认 → 搜
+      $("#qr-table-wrap").onclick = async (e) => {
+        const td = e.target.closest("td.qr-c.keep");
+        if (!td) return;
+        const v = qrState.scores[`${td.dataset.ai}_${td.dataset.ti}`];
+        const query = (v && v.rewrite) || td.textContent.replace(/\d+(\.\d+)?$/, "").trim();
+        if (!confirm(`发起搜索(小红书+公众号 各20):\n${query}`)) return;
+        try {
+          const r = await qrRunSearch(query);
+          showTask(`搜索 · ${r.query_id} ${query}`, r.task_id, null);
+        } catch (err) { toast("搜索启动失败:" + (err.body?.error || err.status), "error"); }
+      };
+      // 搜全部达标:逐格起任务(朴素循环,失败不阻断)
+      $("#qr-search-all").onclick = async () => {
+        const keeps = Object.entries(qrState.scores || {}).filter(([, v]) => v.keep);
+        if (!keeps.length) return;
+        if (!confirm(`将对 ${keeps.length} 个达标 query 各起一次搜索(小红书+公众号 各20),确认?`)) return;
+        let ok = 0;
+        for (const [, v] of keeps) {
+          try { await qrRunSearch(v.rewrite || v.query); ok++; }
+          catch (e) { /* 单格失败不阻断 */ }
+        }
+        toast(`已发起 ${ok}/${keeps.length} 个搜索任务`, "info");
+      };
+```
+
+- [ ] **Step 2: 语法检查 + 提交**
+
+Run: `node --check` 片段 → `✔ JS 语法 OK`。
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/index.html
+git commit -m "feat(mode_workflow): Query 规则 点格搜索 + 搜全部达标(run_search 方向无关)"
+```
+
+---
+
+### Task 6: 浏览器手测清单(交用户)
+
+**Files:** 无(用户在浏览器验证;server 需在跑)
+
+- [ ] **手测项(重启 server 后于 http://localhost:8772 操作)**
+
+1. 顶栏「Query 规则」打开弹层;5 个维度 chips 出现;实质/形式 选一层后**自动展开下一层**。
+2. 下半出现 27×50 表,tier 底色梯度(0 灰 / 1~3 渐绿),tier0 格灰显。
+3. 选若干 chips(如 工具类型=AI、后缀=怎么做),点「生成正交表 & 评估高亮」→ 任务面板轮询 → 完成后 `keep` 格蓝框 + 角标分数;hint 显示 keep 数与成本。
+4. 再点一次同样选择 → 秒回(命中缓存,无新任务)。
+5. 点某个蓝框格 → 确认框显示 query → 确认后起搜索任务(任务面板可见)。
+6. 「搜全部达标」→ 确认 → toast 显示已发起 N 个任务。
+7. 切到 Dataset 工序/工具 tab,能看到新搜出的 query 与按标签入库的帖子。
+
+---
+
+## Self-Review
+
+- **Spec coverage(Part A 前端)**:顶栏按钮+弹层 → Task 1 ✓;chips(工具类型/模态/后缀 扁平 + 实质/形式 接口下钻任意深度)→ Task 2 ✓;27×50 正交表 l1 分组+tier 底色+tier0 灰 → Task 3 ✓;拼词与后端一致(`qrCellQuery` == `_build_cells`)→ Task 3 ✓;评估高亮(POST→轮询→GET→keep 描边+角标,缓存秒回)→ Task 4 ✓;点单格 + 搜全部达标(xhs,gzh×20、方向无关、rewrite 优先)→ Task 5 ✓;接口降级提示 → Task 2 `qrOpen` catch ✓。`动作/类型` 仅作表轴不进 chips(用户确认)✓。
+- **Placeholder scan**:无 TBD;每步给完整代码或精确 HTML/CSS/JS。CSS 若与现有 `.chip` 冲突,Task 2 Step 2 已注明复用不重复定义。
+- **Type consistency**:评分结果键 `${ai}_${ti}`(Task 3/4/5 一致,对齐后端 `<a_idx>_<t_idx>`);`qrSelBody()` 字段(tool_type/modality/suffix/substance_path/form_path)对齐 `POST /api/query_score`;`qrRunSearch` body(query/platforms/max_count,无 mode_type)对齐 Part B 后的 run_search;复用 `showTask(title,taskId,onDone)` 签名一致。

+ 364 - 0
docs/superpowers/plans/2026-06-18-partB-search-routing.md

@@ -0,0 +1,364 @@
+# Part B:搜索→统一评估→按标签路由双表 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让一次搜索只做一次统一评估,再按评估产出的 `知识类型` 标签把帖子路由进 `search_process` / `search_tools`(两者都含则两表都写同一份评估 blob),评估去重改为跨表,杜绝按方向重复打分。
+
+**Architecture:** 现有 `stages/search_eval.py` 当前用 `--mode-type` 提前选单表。本计划把"选表"从"调用方提前指定"改为"评估标签自动路由"。新增两个纯函数到 `db.py`(`route_tables` 路由、`fetch_existing_eval_any` 跨表去重),`search_eval.run()` 改为评估一次 + 分组双表 upsert。`db.upsert_search_posts` 已支持 `table=` 参数,直接按组各调一次复用。
+
+**Tech Stack:** Python 3 + pymysql(同现有),无 pytest(本项目用独立脚本/`python -c` 断言/`--dry-run` 验证,见 CLAUDE.md)。
+
+参考 spec:`docs/superpowers/specs/2026-06-18-query-builder-design.md`(Part B)。
+
+---
+
+## File Structure
+
+- `examples/mode_workflow/db.py` — 新增 `route_tables()`(纯函数,知识类型→表名列表)、
+  `fetch_existing_eval_any()`(跨表评估去重)。这两个是数据/路由职责,归 db.py。
+- `examples/mode_workflow/stages/search_eval.py` — 改 `run()`:去掉 `--mode-type` 选表;
+  评估去重用跨表版;评估后按 `route_tables` 分组、对每张目标表各 upsert 一次;`runs/` 落盘改中性目录。
+- `examples/mode_workflow/README.md` / `流程执行手册.md` — 更新"搜索方向无关、按标签落表"说明。
+
+---
+
+### Task 1: `route_tables` 纯路由函数
+
+**Files:**
+- Modify: `examples/mode_workflow/db.py`(在 `fetch_adopted_tools_cases` 之后、`# ── 评估去重` 注释段之前插入)
+
+- [ ] **Step 1: 写断言(先失败)**
+
+新建临时验证片段并运行(本项目用 `python -c` 风格断言代替 pytest):
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 - <<'PY'
+import db
+assert db.route_tables(["工序"]) == ["search_process"]
+assert db.route_tables(["工具"]) == ["search_tools"]
+assert db.route_tables(["工序","工具"]) == ["search_process","search_tools"]
+assert db.route_tables(["能力"]) == ["search_process"]        # 能力 → 归 process
+assert db.route_tables(["能力","工具"]) == ["search_process","search_tools"]
+assert db.route_tables([]) == ["search_process"]              # 兜底
+assert db.route_tables(None) == ["search_process"]
+print("✔ route_tables OK")
+PY
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Expected: `AttributeError: module 'db' has no attribute 'route_tables'`
+
+- [ ] **Step 3: 实现 `route_tables`**
+
+在 `db.py` 中插入:
+
+```python
+def route_tables(knowledge_types):
+    """知识类型标签 → 落表列表(有序去重)。
+    工序/能力 → search_process;工具 → search_tools;两者都含写两表;空/None 兜底 search_process。
+    评估是统一一套(同一 llm_evaluation blob),故同帖落多表不重复打分,只是多写一行。"""
+    kt = set(knowledge_types or [])
+    tables = []
+    if (kt & {"工序", "能力"}) or not kt:
+        tables.append("search_process")
+    if kt & {"工具"}:
+        tables.append("search_tools")
+    return tables
+```
+
+- [ ] **Step 4: 运行确认通过**
+
+Run: 重跑 Step 1 的片段
+Expected: `✔ route_tables OK`
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/db.py
+git commit -m "feat(mode_workflow): 新增 route_tables 知识类型→落表路由(工序/能力→process,工具→tools)"
+```
+
+---
+
+### Task 2: `fetch_existing_eval_any` 跨表评估去重
+
+**Files:**
+- Modify: `examples/mode_workflow/db.py`(紧跟现有 `fetch_existing_eval` 函数之后,约 `db.py:945` 后)
+
+- [ ] **Step 1: 写断言(先失败)**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 - <<'PY'
+import db
+# 拿一个库里已评过的 case 验证:任一表评过即返回 blob;不存在的 case 返回 None
+c = db._conn()
+with c.cursor() as cur:
+    cur.execute("SELECT case_id FROM search_process WHERE llm_evaluation IS NOT NULL LIMIT 1")
+    row = cur.fetchone()
+c.close()
+if row:
+    e = db.fetch_existing_eval_any(row["case_id"])
+    assert isinstance(e, dict) and isinstance(e.get("相关性"), dict), "应返回有效评估 blob"
+assert db.fetch_existing_eval_any("__不存在的case__") is None
+print("✔ fetch_existing_eval_any OK")
+PY
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Expected: `AttributeError: module 'db' has no attribute 'fetch_existing_eval_any'`
+
+- [ ] **Step 3: 实现**
+
+在 `db.py` 的 `fetch_existing_eval` 函数定义之后插入:
+
+```python
+def fetch_existing_eval_any(case_id):
+    """跨两张搜索表找该 case 最近一条有效评估 blob。
+    评估与表无关(统一一套),任一表评过即可复用,避免同帖在两表各评一次。无则 None。"""
+    for table in ("search_process", "search_tools"):
+        e = fetch_existing_eval(case_id, table)
+        if e:
+            return e
+    return None
+```
+
+- [ ] **Step 4: 运行确认通过**
+
+Run: 重跑 Step 1 片段
+Expected: `✔ fetch_existing_eval_any OK`
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/db.py
+git commit -m "feat(mode_workflow): 新增 fetch_existing_eval_any 跨表评估去重(评估与表无关)"
+```
+
+---
+
+### Task 3: `search_eval.run()` 改为统一评估 + 标签路由双表
+
+**Files:**
+- Modify: `examples/mode_workflow/stages/search_eval.py`(`run()` 内:删第 98 行选表;改第 110 行去重;
+  改第 148–159 行入库+落盘)
+
+- [ ] **Step 1: 删掉提前选表**
+
+删除 `run()` 内这一行(当前 `search_eval.py:98`):
+
+```python
+    table = "search_tools" if args.mode_type == "工具" else "search_process"
+```
+
+- [ ] **Step 2: 评估去重改跨表**
+
+把去重循环里(当前 `search_eval.py:109-110`):
+
+```python
+            for s in sources:
+                e = db.fetch_existing_eval(s["case_id"], table)
+```
+
+改为:
+
+```python
+            for s in sources:
+                e = db.fetch_existing_eval_any(s["case_id"])
+```
+
+- [ ] **Step 3: 入库改为按标签路由双表 + 落盘改中性目录**
+
+把当前 `search_eval.py:148-159`(`for s in sources: s.pop(...)` 到写 runs JSON)整段:
+
+```python
+    for s in sources:
+        s.pop("_image_data_urls", None)
+
+    n = db.upsert_search_posts(args.query_id, args.query, sources, table=table)
+    print(f"🗄️  {table} 入库 {n} 行 · 方向 {args.mode_type or '工序'} · 评估成本 ${cost:.4f}")
+
+    out_dir = MW / "runs" / table
+    out_dir.mkdir(parents=True, exist_ok=True)
+    (out_dir / f"{args.query_id}.json").write_text(json.dumps({
+        "query_id": args.query_id, "query": args.query, "phrasings": phrasings,
+        "platforms": platforms, "total": len(sources), "results": sources,
+    }, ensure_ascii=False, indent=2), encoding="utf-8")
+    return 0
+```
+
+替换为:
+
+```python
+    for s in sources:
+        s.pop("_image_data_urls", None)
+
+    # 按评估标签路由:工序/能力→search_process,工具→search_tools,两者都含→两表都写同一 blob。
+    # 评估只做一次(统一一套),双表只是多写一行,不重复打分。
+    routed = {"search_process": [], "search_tools": []}
+    for s in sources:
+        kt = (s.get("llm_evaluation") or {}).get("知识类型") or []
+        for t in db.route_tables(kt):
+            routed[t].append(s)
+    total = 0
+    for t, group in routed.items():
+        if group:
+            n = db.upsert_search_posts(args.query_id, args.query, group, table=t)
+            total += n
+            print(f"🗄️  {t} 入库 {n} 行")
+    print(f"📊 评估成本 ${cost:.4f} · 共写 {total} 行(双表含同帖重复)")
+
+    out_dir = MW / "runs" / "search"
+    out_dir.mkdir(parents=True, exist_ok=True)
+    (out_dir / f"{args.query_id}.json").write_text(json.dumps({
+        "query_id": args.query_id, "query": args.query, "phrasings": phrasings,
+        "platforms": platforms, "total": len(sources), "results": sources,
+    }, ensure_ascii=False, indent=2), encoding="utf-8")
+    return 0
+```
+
+- [ ] **Step 4: 更新 `--mode-type` 帮助文字为"兼容保留、不再决定路由"**
+
+把 `main()` 里(当前 `search_eval.py:168-169`):
+
+```python
+    p.add_argument("--mode-type", default="", choices=["", "工序", "工具"],
+                   help="解构方向,决定写哪张表(工具 → search_tools;其余 → search_process)")
+```
+
+改为:
+
+```python
+    p.add_argument("--mode-type", default="", choices=["", "工序", "工具"],
+                   help="(兼容保留,已不决定路由)落表现由评估的 知识类型 标签自动路由")
+```
+
+- [ ] **Step 5: 编译确认无语法错**
+
+Run:
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 -m py_compile stages/search_eval.py db.py && echo OK
+```
+Expected: `OK`
+
+- [ ] **Step 6: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/stages/search_eval.py
+git commit -m "feat(mode_workflow): search_eval 统一评估+按知识类型路由双表,评估去重跨表"
+```
+
+---
+
+### Task 4: 端到端真实验证(小批搜索)
+
+**Files:** 无(运行验证)
+
+- [ ] **Step 1: 跑一个小搜索(默认 xhs+gzh,量小省钱)**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
+python3 stages/search_eval.py --query-id qtestB --query "AI 写实人像 怎么做" \
+  --platforms xhs,gzh --max-count 5
+```
+Expected: 日志出现 `🗄️  search_process 入库 N 行`,若有工具帖则另有 `🗄️  search_tools 入库 M 行`,
+末行 `📊 评估成本 $... · 共写 ... 行`。
+
+- [ ] **Step 2: 校验双标签帖两表都在、blob 一致、且只评一次**
+
+```bash
+python3 - <<'PY'
+import db, json
+c = db._conn()
+with c.cursor() as cur:
+    # 该 query 下同时落入两表的 case = 含双标签
+    cur.execute("""SELECT p.case_id FROM search_process p
+                   JOIN search_tools t ON p.case_id=t.case_id AND p.query_id=t.query_id
+                   WHERE p.query_id='qtestB' LIMIT 1""")
+    row = cur.fetchone()
+    if row:
+        cid = row["case_id"]
+        cur.execute("SELECT llm_evaluation FROM search_process WHERE query_id='qtestB' AND case_id=%s", (cid,))
+        a = cur.fetchone()["llm_evaluation"]
+        cur.execute("SELECT llm_evaluation FROM search_tools  WHERE query_id='qtestB' AND case_id=%s", (cid,))
+        b = cur.fetchone()["llm_evaluation"]
+        assert a == b, "两表同帖 llm_evaluation 应为同一份(只评一次)"
+        print(f"✔ 双标签帖 {cid} 两表 blob 一致")
+    else:
+        print("ℹ 本批无双标签帖(正常),仅验证单表路由")
+c.close()
+PY
+```
+Expected: `✔ 双标签帖 ... 两表 blob 一致` 或 `ℹ 本批无双标签帖`。
+
+- [ ] **Step 3: 清理测试数据**
+
+```bash
+python3 - <<'PY'
+import db
+c = db._conn()
+with c.cursor() as cur:
+    for t in ("search_process","search_tools"):
+        cur.execute(f"DELETE FROM {t} WHERE query_id='qtestB'")
+c.close(); print("✔ 已清理 qtestB")
+PY
+```
+
+- [ ] **Step 4: 提交(无代码改动则跳过)**
+
+本任务仅验证,无提交。
+
+---
+
+### Task 5: 文档更新
+
+**Files:**
+- Modify: `examples/mode_workflow/README.md`(数据流段)
+- Modify: `examples/mode_workflow/流程执行手册.md`(步骤 1+2 要点)
+
+- [ ] **Step 1: README 数据流改为"方向无关 + 标签路由"**
+
+把 README「数据流」代码块里:
+
+```
+新建搜索(UI) → server 子进程 stages/search_eval.py → search_process / search_tools(方向分表)
+```
+
+改为:
+
+```
+新建搜索(UI) → server 子进程 stages/search_eval.py → 统一评估 → 按 知识类型 标签路由
+                                                     (工序/能力→search_process,工具→search_tools,两者都含写两表)
+```
+
+- [ ] **Step 2: 手册步骤 2 加一句路由说明**
+
+在 `流程执行手册.md`「步骤 1 + 2」的"要点"列表末尾追加一行:
+
+```
+- 入库方向由评估的 `知识类型` 标签自动决定(工序/能力→search_process,工具→search_tools,两者都含两表都写);
+  `--mode-type` 已不决定落表。
+```
+
+- [ ] **Step 3: 提交**
+
+```bash
+cd /Users/max_liu/max_liu/company/Agent
+git add examples/mode_workflow/README.md examples/mode_workflow/流程执行手册.md
+git commit -m "docs(mode_workflow): 搜索入库改为按知识类型标签路由双表"
+```
+
+---
+
+## Self-Review
+
+- **Spec coverage(Part B)**:req#2 标签路由(工序/能力→process、工具→tools、both→双表同 blob、空→兜底 process)→ Task 1+3 ✓;评估去重跨表 → Task 2+3 ✓;零重复打分(评估一次)→ Task 3 的"评估后再路由"结构保证 ✓;`--mode-type` 退化 → Task 3 Step 4 ✓。req#1 的"默认 xhs,gzh×20 / 触发方式"属前端+run_search,归 Part A 计划,不在本计划。
+- **Placeholder scan**:无 TBD/TODO;每步含可运行命令与完整代码。
+- **Type consistency**:`route_tables` 返回 `list[str]`,Task 3 以 `for t in db.route_tables(kt)` 消费一致;`fetch_existing_eval_any(case_id)` 单参,Task 3 Step 2 调用一致;`upsert_search_posts(query_id, query_text, results, table=)` 签名与现有一致。

+ 223 - 0
docs/superpowers/specs/2026-06-18-query-builder-design.md

@@ -0,0 +1,223 @@
+# Query 规则组织器 + 正交评估高亮 — 设计
+
+日期:2026-06-18
+所属:`examples/mode_workflow`
+
+## 目标
+
+在工作台「新建搜索」旁增加「Query 规则」入口,打开一个弹层:用内容树各维度的词
+正交生成「动作 × 类型」候选 query 矩阵,用 Sonnet 评估挑出**有意义、人话、有助于
+内容制作知识库目的**的组合并在表中高亮,点高亮格即可直接发起搜索。
+
+把"凭直觉拍 query"变成"按内容树系统性铺词 → LLM 筛 → 一键搜 → 标签自动入库 → 分 tab 解构"
+的端到端闭环(搜索/入库/解构见 Part B/C)。
+
+## 背景与复用
+
+- 表的骨架直接复用 `reference/judged_matrix.json`:27 动作(列,按 `获取/提取/生成/修改`
+  → l1 → 叶子分组)× 50 类型(行,按 `程序控制/数据复用/内容/知识` → l1 → 叶子分组),
+  每格 `{tier:0-3, r:理由, q:[示例query]}`。1350 格中 643 个 tier≥1(有效),707 个 tier0。
+  `server.py` 已加载它(`MATRIX_FILE`,Dashboard 覆盖度在用)。
+- flat 维度词表 工具类型/模态/后缀 移植自参考稿 `~/Downloads/query-builder.html` 的 `DIMS`
+  常量,内嵌进 `index.html`。
+- **实质/形式 维度数据来自外部接口(深层分类树,不写死)**:
+  `GET https://library.aiddit.com/api/pattern/executions/{exec_id}/category-tree?source_type=实质|形式`
+  (exec_id 默认 401;返回 `{tree:[{category_id,name,classified_as,total_element_count,children:[...]}]}`,
+  6+ 层)。server 端直连真实域名拉取并缓存,前端逐级下钻选节点,**选中节点 + 祖先路径**作为
+  Sonnet 领域上下文。注:用户给的 `/lib-proxy` 是前端 dev vite 代理(rewrite 去前缀),后端不走它。
+- 搜索发起复用既有 `POST /api/run_search` → spawn `stages/search_eval.py` → `{task_id, query_id}`;
+  Part B 后**搜索方向无关**(落表由评估标签决定),默认 `platforms=xhs,gzh`、`max_count=20`。
+- LLM 调用复用 `agent.llm.openrouter.create_openrouter_llm_call(model=...)`(同 `procedure_extract`)。
+- 子进程 + 轮询复用 `server.py:_spawn_task` / `_task_status`。
+
+## 四项关键决策(已与用户确认)
+
+1. **表骨架** = judged_matrix(动作×类型,tier 决定底色,附示例 q)。
+2. **格子词串** = `[工具类型] 动作叶 类型叶 [模态] [后缀]`,工具类型/模态/后缀 取 chips 单选值统一拼入;
+   **实质/形式不进表、不拼词**,仅把选中的实质/形式节点(含祖先路径,数据来自上述外部接口)
+   作为评分上下文供 Sonnet 理解领域。
+3. **评分范围** = 只评 643 个 tier≥1 格 × 当前 chips 上下文,分批交 Sonnet。
+4. **高亮后用途** = 达标格(keep=true)可**点单格**或**「搜全部达标」批量**发起搜索;渠道默认
+   `xhs,gzh` 各 20 帖、搜索方向无关(落表由 Part B 评估标签决定)。
+
+## 架构与组件
+
+```
+[新建搜索] [Query规则]                         ← index.html 顶栏新增按钮
+                │
+                ▼ 全屏 modal(复用 .modal-bg 模式)
+┌─ 上半:维度 chips(移植 query-builder DIMS 渲染) ───────────────┐
+│   工具类型/模态(flat,单选)·后缀 · 动作/类型/实质/形式(逐级下钻)   │
+│   [生成正交表 & 评估高亮]  按钮                                  │
+├─ 下半:动作×类型 正交表(27列×50行,l1/l2 分组,sticky 表头)──────┤
+│   · 每格 query = [工具类型] 动作叶 类型叶 [模态] [后缀]          │
+│   · tier 底色(0灰/1浅/2中/3深);tier0 格置灰不可选               │
+│   · Sonnet keep=true 的格 → 高亮描边 + 角标综合分                │
+│   · 点达标格 / 「搜全部达标」→ /api/run_search(xhs+gzh×20,方向无关)│
+└────────────────────────────────────────────────────────────────┘
+```
+
+### 前端(`index.html`,单文件)
+
+- **顶栏按钮**:在「+ 新建搜索」旁加「Query 规则」,`onclick` 打开 `#query-modal`。
+- **维度 chips 区**:工具类型/模态/后缀 移植 `DIMS` 常量(flat 单选);动作/类型 三级下钻;
+  **实质/形式 从 `GET /api/category_tree?source_type=实质|形式` 拉树做任意深度逐级下钻**
+  (`renderDim` 推广到 N 级),选中节点记录其 `name` 与祖先路径数组。
+- **正交表区**:拉 `GET /api/query_matrix` 拿 actions/types/matrix;按 l1/l2 分组渲染表头
+  与行分组(参考 Image #2)。每格文本 = 拼词结果;底色按 tier;tier0 置灰。
+- **评估**:点「生成正交表 & 评估高亮」→ `POST /api/query_score`(带当前选择)→ 轮询
+  `/api/task_status` → 成功后 `GET /api/query_score?sel=<hash>` 取每格 verdict → 给
+  `keep=true` 的格加高亮 class + 角标分数。
+- **发起搜索**:点单个达标格 → 确认弹框(query=rewrite 或原串)→ `POST /api/run_search`;
+  另有**「搜全部达标」**按钮,对所有 keep=true 格循环发起。渠道默认 `xhs,gzh` 各 20、方向无关。
+
+### 后端(`server.py` 新增)
+
+| 接口 | 方法 | 作用 |
+|---|---|---|
+| `/api/query_matrix` | GET | 返回 `judged_matrix.json`(actions/types/matrix);带 ETag/304 |
+| `/api/category_tree` | GET | `?source_type=实质\|形式` → 直连 + 缓存 `library.aiddit.com` 分类树;带 ETag |
+| `/api/query_score` | POST | body=当前选择 → 命中缓存直接返回结果;否则 `_spawn_task` 起 `stages/query_score.py`,返回 `{task_id, sel}` |
+| `/api/query_score` | GET | `?sel=<hash>` 读 `.cache/query_score/<hash>.json` 返回结果(未就绪 404/202) |
+
+`POST /api/query_score` body:
+```json
+{"tool_type":"AI|无","modality":"图片|无","suffix":"怎么做|无",
+ "substance_path":["表象","实体","物品"],"form_path":["呈现","视觉"],
+ "model":"anthropic/claude-sonnet-4-6"}
+```
+
+### 评分脚本(新增 `stages/query_score.py`)
+
+职责单一:输入选择上下文 → 输出每格 verdict JSON。
+
+- 读 `reference/judged_matrix.json`,筛 tier≥1 格,对每格拼 query 串。
+- 异步并发分批(`asyncio` + `Semaphore`,默认并发 5、每批 ~40 格)调
+  `create_openrouter_llm_call(model)`,用下方 Prompt 打分。
+- 结果写 `.cache/query_score/<sel_hash>.json`:
+  ```json
+  {"sel": {...}, "model": "...", "matrix_sig": "<judged_matrix 指纹>",
+   "cells": {"<action_idx>_<type_idx>": {"query":"...","natural":8,"findable":7,
+             "useful":9,"keep":true,"rewrite":"...","reason":"...","score":8.0}}}
+  ```
+- `sel_hash` = 稳定哈希(选择 + model + matrix_sig),**同选择不重复付费**
+  (承袭项目一贯的去重省钱思路)。`--force` 可绕过缓存重评。
+- 命令行可独立跑:`python stages/query_score.py --tool-type AI --modality 图片 --suffix 怎么做 ...`
+
+路径锚点同其它 `stages/` 脚本(`parents[3]`=Agent,`parent.parent`=mode_workflow,`import db` 非必需)。
+
+## 评分 Prompt(核心交付)
+
+给 Sonnet 的 system + 每批 user。判据:当前 chips 上下文下,每个 tier≥1 的
+`动作叶×类型叶` 拼出的 query 是否「人话、真实创作者会搜、公共域能搜到内容制作 how-to/工具」。
+
+```
+你是「内容制作知识库」的检索词评审。我们要从小红书/公众号等公共平台搜到
+"怎么做某类内容"的工序/工具教程。现给定一批由维度正交生成的候选 query。
+
+【固定上下文(本批所有候选共享)】
+工具类型: {tool_type|无}   模态: {modality|无}   后缀: {suffix|无}
+(实质/形式不参与拼词,仅供你理解领域定位:
+  实质路径={substance_path 如 表象›实体›物品}  形式路径={form_path 如 呈现›视觉})
+
+【候选列表】每条 = 动作 + 类型 + 上下文词拼成的 query:
+{idx}. "{query}"   (动作={action} 类型={type} 内容树tier={tier})
+
+【逐条打分,只看这条 query 本身】
+- natural(0-10): 像不像真人会在搜索框打的人话(越口语、越具体越高;生造词/学术腔低)
+- findable(0-10): 公共域是否真有对口的"怎么做"内容能被搜到(参考 tier,但以 query 实际可搜性为准)
+- useful(0-10): 命中的内容对"内容制作知识库"是否有价值(教程/方法/工具 高;成品展示/抽象概念 低)
+- keep(bool): 三者均衡后是否值得真的拿去搜(有意义=true)
+- rewrite: 若 keep 但措辞生硬,给一个更像人话的等价搜索词(否则原样)
+- reason: 一句话理由(≤30字)
+
+只输出 JSON 数组: [{"idx":1,"natural":8,"findable":7,"useful":9,"keep":true,
+"rewrite":"...","reason":"..."}]
+```
+
+- 综合分 `score` = 加权(默认 `natural*0.4 + findable*0.3 + useful*0.3`),角标显示。
+- 高亮:`keep=true` 描边 + 角标分数;点击用 `rewrite`(空则原串)发起搜索。
+- Prompt 文件放 `prompts/query_score_system.md`,可单独迭代(同其它 prompt 约定)。
+
+## 数据流
+
+```
+开弹层 → /api/query_matrix(表) + /api/category_tree(实质/形式树) → 选维度 → [评估高亮]
+  → POST /api/query_score → (缓存命中? 直接返回 : spawn stages/query_score.py)
+  → 轮询 /api/task_status → GET /api/query_score?sel=hash → 每格 verdict → 高亮 keep 格
+  → 点单格/搜全部达标 → POST /api/run_search(xhs+gzh×20,方向无关)
+  → stages/search_eval.py 搜一次 + 统一评估一次 → 按 知识类型 路由写 search_process/search_tools
+  → Dataset 工序/工具 tab 各自「解构全部已采纳」→ procedure_extract / tool_extract
+```
+
+## Part B:搜索 → 统一评估 → 按标签路由双表(req#1 + #2)
+
+**已确认事实**:评估是**一套统一逻辑**,输出 `知识类型 ⊆ {工序,能力,工具}` + 单一
+`overall_score`(`llm_evaluate_sources.py:55/264`、`db.py:overall_score`),不分方向。
+故"搜一次、评一次、按标签落表",零重复打分。
+
+### 触发(req#1)
+
+- query-builder 达标格(`keep=true`)即可发起搜索,两种方式(用户要"都要"):
+  - **点单格** → 一个搜索任务。
+  - **「搜全部达标」** → 对所有 keep=true 格循环各起一个搜索任务。
+- 渠道默认 `xhs,gzh`,`--max-count 20`(每渠道 20)。**搜索方向无关**,落表由标签决定。
+
+### 统一评估 + 标签路由(req#2)
+
+- `stages/search_eval.py` 改造:搜一次 → 评估一次(产出 知识类型 + overall_score)→ 路由:
+  - `知识类型 ∩ {工序, 能力} ≠ ∅` → upsert `search_process`
+  - `知识类型 ∩ {工具} ≠ ∅` → upsert `search_tools`
+  - 两者都含 → **两表都写,同一 `llm_evaluation` blob**(零重复打分)
+  - 知识类型 为空(兜底)→ `search_process`
+- **评估去重跨表**:`db.fetch_existing_eval` 改为"任一表评过即复用"(eval blob 与表无关),
+  避免同帖在两表各评一次。成本:每帖最多评一次,与落表数无关。
+- `--mode-type` 退化为兼容参数,不再决定路由(路由全自动)。
+- `runs/` JSON 保留全量(含能力-only 帖)。
+
+## Part C:tab 已采纳 → 对应解构(req#3)
+
+- UI 已有工序/工具模式切换(`#m-process` / `#m-tools`)。各模式下加**「解构全部已采纳」**按钮:
+  - 工序模式 → `POST /api/extract_process`(该 query 下 `search_process` 全部已采纳 case)→ `procedure_extract`
+  - 工具模式 → `POST /api/extract_tools`(`search_tools` 全部已采纳 case)→ `tool_extract`
+- 复用已支持的"`--query-id` = 全部已采纳"脚本能力;`server.py` 的 `/api/extract_*`
+  **放宽 `case_ids` 为可选**:缺省时服务端用 `db.fetch_posts(query_id, 方向)` 的 `adopted` 过滤
+  取全部已采纳 case。解构去重 / 认领锁 / `(query,case,version,seq)` 唯一键(前序已做)继续生效。
+
+## 错误处理
+
+- LLM 某批失败/超时:该批格标记 `error`(不高亮、可重试),不阻断其它批;脚本退出码非 0 时
+  前端提示"部分批次失败,可重评"。
+- 评估无标签 / 知识类型解析失败:兜底落 `search_process`,不丢帖(Part B)。
+- 「搜全部达标」批量:逐格起任务、各自轮询,单格失败不阻断其余。
+- judged_matrix 缺失/解析失败:`/api/query_matrix` 返回 500,弹层提示。
+- 实质/形式 接口不可达:弹层提示该维度暂不可选,评分照常进行(上下文路径缺省为空),不阻断。
+- 缓存文件半写:写临时文件再原子 rename,避免读到半截。
+- 点格搜索沿用 run_search 既有校验(缺 query 报错)。
+
+## 测试
+
+- `stages/query_score.py --dry-run`:只拼词 + 打印将评的格数/示例,不调 LLM(验证筛选与拼词)。
+- 小范围真评:`--limit 20` 只评前 20 格,核对 JSON 结构与缓存落盘。
+- 前端:手测弹层渲染、tier 底色、轮询、高亮、点格确认与发起搜索。
+- 缓存:同选择二次点击应秒回(命中缓存,无 LLM 调用)。
+- Part B:构造/挑一帖含 `工序`+`工具` 双标签 → 应在两表各 1 行、`llm_evaluation` 一致,
+  且评估成本日志显示只评一次(跨表去重生效)。
+- Part C:某 tab「解构全部已采纳」→ 只解构该方向已采纳 case;重复点应被去重/认领锁/唯一键拦住。
+
+## 范围与非目标(YAGNI)
+
+- 「搜全部达标」先朴素循环逐格起任务,暂不做并发上限/限流(量大再加节流)。
+- 不把评分结果入 MySQL(落 `.cache/` 文件即可,按选择哈希复用)。
+- 不做维度词表的在线编辑(flat 词表内嵌 `index.html`,改词改常量)。
+- 实质/形式不进正交、不进拼词,仅作评分上下文(数据来自外部接口,逐级下钻选节点)。
+
+## 涉及文件
+
+- 改:`index.html`(按钮+弹层+表+交互、「搜全部达标」、各 tab「解构全部已采纳」)、
+  `server.py`(query_matrix/category_tree/query_score 接口、run_search 默认渠道、extract_* 放宽 case_ids)、
+  `stages/search_eval.py`(去 mode_type 路由、统一评估、按标签双表路由、跨表去重)、
+  `db.py`(`fetch_existing_eval` 跨表、双表 upsert 复用)、`README.md`/`流程执行手册.md`。
+- 增:`stages/query_score.py`、`prompts/query_score_system.md`。
+- 只读复用:`reference/judged_matrix.json`、`agent.llm.openrouter`、`stages/{procedure,tool}_extract.py`。
+- 外部依赖:`library.aiddit.com` category-tree(server 端直连 + `.cache/category_tree/` 缓存)。

+ 39 - 5
examples/mode_workflow/README.md

@@ -2,8 +2,10 @@
 
 MySQL 四表(search_process / search_tools / mode_process / mode_tools)为唯一事实源的单页工作台:
 Dashboard(结果/过程指标可视化)、Dataset(query → 帖子 → 工序/工具解构)、聚类库(占位)。
+顶栏「**Query 规则**」按内容树维度铺词 → Sonnet 评分 → 高亮达标组合 → 一键搜(见下「Query 规则组织器」)。
 
-设计文档:`docs/superpowers/specs/2026-06-12-mode-workflow-design.md`
+设计文档:`docs/superpowers/specs/2026-06-12-mode-workflow-design.md`、
+`docs/superpowers/specs/2026-06-18-query-builder-design.md`(Query 规则 + 端到端管线)
 
 ## 启动
 
@@ -18,15 +20,17 @@ python server.py              # http://localhost:8772
 | 文件 | 职责 |
 |---|---|
 | `db.py` | 四表 DDL + 全部读写(读 .env MYSQL_*);连接走 `PooledDB` 池(远程 RDS 每次握手 ~0.5s,池复用避免每请求重连) |
-| `server.py` | 页面 + API + 解构任务子进程管理(端口 8772);`/api/dashboard` 结果带缓存(任务完成时作废 + 60s 兜底 TTL),`/api/extract` 等带 ETag/304 |
-| `index.html` | 单文件前端:Dashboard / Dataset / 聚类库 |
+| `server.py` | 页面 + API + 解构任务子进程管理(端口 8772);`/api/dashboard` 结果带缓存(任务完成时作废 + 60s 兜底 TTL),`/api/extract` 等带 ETag/304;Query 规则接口 `query_matrix`/`category_tree`/`query_score` |
+| `index.html` | 单文件前端:Dashboard / Dataset / 聚类库 / Query 规则弹层 |
 | `stages/search_eval.py` | 任意 query 搜索+评估 → search_process / search_tools(按解构方向分表) |
 | `stages/procedure_extract.py` | 工序解构(LLM 直出)→ mode_process |
 | `stages/tool_extract.py` | 工具解构 → mode_tools |
 | `stages/import_process_knowledge.py` | 已采纳工序(mode_process 最新版)→ 知识导入接口;**读 DB 非本地文件**,采纳口径同 Dashboard(`db.is_adopted_rel`) |
 | `prompts/` | 工序/工具解构 system prompt(可单独迭代) |
 | `reference/judged_matrix.json` | 内容树(27 动作×50 类型),Dashboard 覆盖度用 |
-| `runs/` | 运行日志与调试副本(gitignore):search_process / search_tools / mode_process / mode_tools / logs |
+| `stages/query_score.py` | Query 正交格评分:tier≥1 格 × 维度上下文 → Sonnet 打分,结果缓存 `.cache/query_score/` |
+| `prompts/query_score_system.md` | 评分 system prompt(可单独迭代) |
+| `runs/` | 运行日志与调试副本(gitignore):search / mode_process / mode_tools / logs |
 
 搜索/评估/转写引擎函数只读复用
 `examples/process_pipeline/script/search_eval/search_and_evaluate.py`,本目录不复制引擎代码。
@@ -34,8 +38,11 @@ python server.py              # http://localhost:8772
 ## 数据流
 
 ```
-新建搜索(UI) → server 子进程 stages/search_eval.py → search_process / search_tools(方向分表)
+Query 规则(UI) → /api/query_score(Sonnet 评 tier≥1 格)→ 高亮达标(keep 且 综合分≥阈值)→ 点格/搜全部达标
+新建搜索(UI) → server 子进程 stages/search_eval.py → 统一评估 → 按 知识类型 标签路由
+                                                     (工序/能力→search_process,工具→search_tools,两者都含写两表)
 选帖解构(UI) → server 子进程 stages/{procedure,tool}_extract.py → mode_process / mode_tools
+              (Dataset 工序/工具 tab「解构全部已采纳」一键解构该 query 该方向全部采纳帖)
 Dashboard    → /api/dashboard 实时聚合四表(内容树覆盖按 steps 的 action×type 命中有效节点)
 ```
 
@@ -49,6 +56,33 @@ query 搜到时只真实解构一次——`stages/{procedure,tool}_extract.py` 
 行补齐关联(`cost=0`),不再付费重跑。换 prompt/模型要对比时传 `--force`(API `force:true`)
 跳过去重。`runs/backfill_links.py` 是事后扫尾工具,复用同一 `link_process`。
 
+## Query 规则组织器(铺词 → 评分 → 搜索)
+
+顶栏「Query 规则」打开弹层,把"凭直觉拍 query"变成系统化铺词 + LLM 筛选:
+
+- **维度 chips**:工具类型/模态/后缀(扁平单选,拼入 query 词)、实质/形式(从
+  `library.aiddit.com` 分类树逐级下钻,**仅作 Sonnet 领域上下文**)、动作/类型(从
+  `judged_matrix` 的 l1/l2 下钻,**筛选下方表格显示**)。
+- **正交表**:`动作 × 类型` 整张 27×50(来自 `reference/judged_matrix.json`),每格
+  query = `[工具类型] 动作叶 类型叶 [模态] [后缀]`,tier 决定底色。
+- **评估高亮**:`POST /api/query_score` 起 `stages/query_score.py`,对 643 个 tier≥1 格在
+  当前维度上下文下用 Sonnet 打分(natural/findable/useful + keep + rewrite + reason),
+  综合分 `= natural*0.4 + findable*0.3 + useful*0.3`。结果按选择哈希缓存
+  `.cache/query_score/<sel>.json`,**同选择不重复付费**。
+- **达标 = keep 且 综合分 ≥ 阈值**(弹层可调,默认 7):达标格蓝框高亮 + 角标分数,
+  点格弹气泡(分数拆解 / 理由 / 发起搜索 / 复制)。
+- **发起搜索**:点单格 或「搜全部达标」→ `POST /api/run_search`(默认 `xhs,gzh` 各 20、
+  方向无关,落表由评估标签自动路由)。
+
+命令行单独跑评分(不依赖 UI):
+
+```bash
+python stages/query_score.py --tool-type AI --modality 图片 --suffix 怎么做 --dry-run  # 只拼词,不调 LLM
+python stages/query_score.py --tool-type AI --suffix 怎么做 --limit 20 --sel _t        # 小样真评(写 .cache)
+```
+
+外部依赖:`library.aiddit.com` 的 category-tree 接口(实质/形式),server 端直连 + `.cache/category_tree/` 缓存。
+
 ## 知识导入(工序上传)
 
 把 DB 中**已采纳**的工序解构导入到知识接口(`/api/v1/knowledge/ingest`)。数据来源是

+ 39 - 8
examples/mode_workflow/db.py

@@ -512,11 +512,12 @@ def fetch_queries(mode="process"):
                                    COUNT(*) AS post_count
                             FROM {table} GROUP BY query_id ORDER BY query_id""")
             queries = cur.fetchall()
-            cur.execute(f"""SELECT query_id, overall_score, llm_evaluation, publish_time
-                            FROM {table}""")
+            # 采纳数:SQL 直取 rel/repro 标量算,**不拉整表 llm_evaluation**(旧版全表 blob,切 tab 巨慢)
+            cur.execute(f"""SELECT query_id, overall_score, publish_time,
+                                   {_REL_SQL} AS rel, {_REPRO_SQL} AS repro FROM {table}""")
             hits = {}
             for r in cur.fetchall():
-                if is_adopted(r["overall_score"], _loads(r["llm_evaluation"]), r["publish_time"]):
+                if is_adopted_rel(r["overall_score"], r["rel"], r["publish_time"], r["repro"]):
                     hits[r["query_id"]] = hits.get(r["query_id"], 0) + 1
             cur.execute("SELECT query_id, COUNT(DISTINCT case_id) AS n FROM mode_process GROUP BY query_id")
             np = {r["query_id"]: r["n"] for r in cur.fetchall()}
@@ -532,12 +533,19 @@ def fetch_queries(mode="process"):
 
 
 def fetch_posts(query_id, mode="process"):
-    """某方向搜索表里某 query 的全部帖子(JSON 列已解析),带 has_process/has_tools 标记。"""
+    """列表用:只取列表所需列 + SQL 直取 adopted 标量,**不拉 body/videos/llm_evaluation 大字段**
+    (llm_evaluation ~1.5MB/帖,旧版 SELECT * 切 tab/选 query 要几十 MB 过远程 RDS,故慢)。
+    正文/评分等详情按需走 fetch_post。带 adopted/has_process/has_tools;adopted 口径用
+    is_adopted_rel(与 is_adopted 完全一致,rel/repro 由 _REL_SQL/_REPRO_SQL 直取标量)。"""
     table = _search_table(mode)
     conn = _conn()
     try:
         with conn.cursor() as cur:
-            cur.execute(f"""SELECT * FROM {table} WHERE query_id=%s
+            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} WHERE query_id=%s
                             ORDER BY overall_score DESC, id""", (query_id,))
             rows = cur.fetchall()
             cur.execute("SELECT DISTINCT case_id FROM mode_process WHERE query_id=%s", (query_id,))
@@ -547,12 +555,12 @@ def fetch_posts(query_id, mode="process"):
     finally:
         conn.close()
     for r in rows:
-        for col in ("images", "videos", "found_by", "knowledge_type", "llm_evaluation"):
+        for col in ("images", "found_by", "knowledge_type"):
             r[col] = _loads(r[col])
-        r["adopted"] = is_adopted(r["overall_score"], r["llm_evaluation"], r["publish_time"])
+        r["adopted"] = is_adopted_rel(r["overall_score"], r.pop("rel", None),
+                                      r["publish_time"], r.pop("repro", None))
         r["has_process"] = r["case_id"] in hp
         r["has_tools"] = r["case_id"] in ht
-        r.pop("created_at", None); r.pop("updated_at", None)
     return rows
 
 
@@ -921,6 +929,19 @@ def fetch_adopted_tools_cases(query_id=None):
     return sorted(set(cases))
 
 
+def route_tables(knowledge_types):
+    """知识类型标签 → 落表列表(有序去重)。
+    工序/能力 → search_process;工具 → search_tools;两者都含写两表;空/None 兜底 search_process。
+    评估是统一一套(同一 llm_evaluation blob),故同帖落多表不重复打分,只是多写一行。"""
+    kt = set(knowledge_types or [])
+    tables = []
+    if kt & {"工具"}:
+        tables.append("search_tools")
+    if (kt & {"工序", "能力"}) or not tables:   # 工序/能力,或没命中任何已知标签 → 兜底 process
+        tables.insert(0, "search_process")
+    return tables
+
+
 # ── 评估去重:复用 query 无关分,只重算 query 相关分(search_eval.py 用)──────────
 
 def fetch_existing_eval(case_id, table="search_process"):
@@ -945,6 +966,16 @@ def fetch_existing_eval(case_id, table="search_process"):
     return None
 
 
+def fetch_existing_eval_any(case_id):
+    """跨两张搜索表找该 case 最近一条有效评估 blob。
+    评估与表无关(统一一套),任一表评过即可复用,避免同帖在两表各评一次。无则 None。"""
+    for table in ("search_process", "search_tools"):
+        e = fetch_existing_eval(case_id, table)
+        if e:
+            return e
+    return None
+
+
 def update_post_eval(query_id, case_id, evaluation, table="search_process"):
     """用新的评估 blob 覆盖某 (query, case) 行的 llm_evaluation,并同步重算派生列
     overall_score、knowledge_type(口径同 upsert_search_posts)。返回受影响行数。"""

+ 426 - 5
examples/mode_workflow/index.html

@@ -1787,7 +1787,7 @@
         bottom: 22px;
         width: 440px;
         max-height: 55vh;
-        z-index: 60;
+        z-index: 130;   /* 高于弹层(modal-bg 70/dialog 120),搜索进度日志始终可见 */
         display: flex;
         flex-direction: column;
         border: 1px solid var(--line-dark);
@@ -2068,6 +2068,55 @@
           transform: translateY(-8px);
         }
       }
+      /* Query 规则弹层 */
+      .qr-box { background:#fff; border-radius:14px; box-shadow:0 10px 44px rgba(0,0,0,.18);
+                width:96vw; max-width:1200px; max-height:92vh; display:flex; flex-direction:column; overflow:hidden; }
+      .qr-head { display:flex; align-items:center; padding:15px 20px; border-bottom:1px solid #eee; }
+      .qr-title { font-size:15px; font-weight:600; color:#1a1a1a; }
+      .qr-x { margin-left:auto; border:none; background:none; font-size:16px; color:#999; cursor:pointer; padding:4px 9px; border-radius:7px; line-height:1; }
+      .qr-x:hover { background:#f2f2f2; color:#333; }
+      .qr-bar { display:flex; align-items:center; gap:10px; padding:11px 20px; border-bottom:1px solid #f0f0f0; background:#fcfcfc; }
+      .qr-hint { color:#888; font-size:12px; margin-left:4px; }
+      .qr-thr-lab { color:#666; font-size:12px; }
+      .qr-thr { width:46px; padding:3px 5px; border:1px solid #ddd; border-radius:6px; font-size:12px; }
+      #qr-score:disabled { opacity:.6; cursor:wait; }
+      .ui-confirm-bg { position:fixed; inset:0; background:rgba(0,0,0,.38); z-index:200; display:flex; align-items:center; justify-content:center; }
+      .ui-confirm { background:#fff; border-radius:14px; box-shadow:0 14px 50px rgba(0,0,0,.28); width:400px; max-width:92vw; padding:22px 24px; }
+      .ui-confirm-msg { font-size:14px; color:#1a1a1a; line-height:1.6; white-space:pre-line; margin-bottom:18px; }
+      .ui-confirm-acts { display:flex; justify-content:flex-end; gap:10px; }
+      .qr-dims { padding:8px 20px 10px; max-height:32vh; overflow:auto; border-bottom:1px solid #f0f0f0; }
+      .qr-row { display:flex; align-items:flex-start; gap:10px; padding:4px 0; }
+      .qr-row + .qr-row { border-top:1px solid #f6f6f6; }
+      .qr-lab { min-width:64px; color:#999; font-size:12.5px; padding-top:5px; flex-shrink:0; }
+      .qr-chips { display:flex; flex-wrap:wrap; gap:6px; flex:1; }
+      .chip { padding:4px 13px; border-radius:16px; font-size:12.5px; cursor:pointer; border:1.5px solid #e2e2e2; background:#fff; color:#555; line-height:1.4; }
+      .chip:hover { border-color:#bbb; color:#222; }
+      .chip.on { background:#1a1a1a; border-color:#1a1a1a; color:#fff; font-weight:500; }
+      .chip.axis.on { background:#2563eb; border-color:#2563eb; }
+      .qr-table-wrap { overflow:auto; flex:1; }
+      .qr-tab { border-collapse:collapse; font-size:11px; }
+      .qr-tab th, .qr-tab td { border:1px solid #eee; padding:3px 7px; white-space:nowrap; }
+      .qr-tab thead th { position:sticky; top:0; background:#f7f7f7; z-index:2; }
+      .qr-corner { position:sticky; left:0; z-index:3; background:#eef0f3; font-weight:600; color:#555; }
+      .qr-th { position:sticky; left:0; background:#fafafa; z-index:1; text-align:left; color:#444; }
+      .qr-tl1 { background:#eef0f3; font-weight:600; color:#444; }
+      .qr-gh { background:#e8ebef; font-weight:600; color:#333; text-align:center; }
+      .qr-ah { background:#f3f4f6; color:#555; text-align:center; }
+      .qr-c.t0 { background:#fafafa; color:#bbb; } .qr-c.t1 { background:#f1f8f1; }
+      .qr-c.t2 { background:#d8ecd8; } .qr-c.t3 { background:#b9ddb9; }
+      .qr-c.dead { color:#ccc; }
+      .qr-c.keep { background:#fff3bf !important; box-shadow:inset 0 0 0 2px #f59e0b; color:#111; font-weight:700; cursor:pointer; }
+      .qr-c.keep:hover { background:#ffe066 !important; }
+      .qr-bd { display:inline-block; margin-left:5px; font-style:normal; font-size:11px; font-weight:800; color:#fff; background:#f59e0b; padding:0 6px; border-radius:9px; }
+      .qr-pop { position:fixed; z-index:60; width:300px; background:#fff; border:1px solid #e2e2e2;
+                border-radius:11px; box-shadow:0 8px 30px rgba(0,0,0,.18); padding:13px 15px; font-size:12.5px; color:#333; }
+      .qr-pop .q { font-size:13.5px; font-weight:600; color:#1a1a1a; margin-bottom:2px; word-break:break-all; }
+      .qr-pop .orig { font-size:11px; color:#aaa; margin-bottom:8px; }
+      .qr-pop .sc { display:flex; align-items:baseline; gap:8px; margin:6px 0; }
+      .qr-pop .sc b { font-size:22px; color:#2563eb; }
+      .qr-pop .dim { color:#888; font-size:11.5px; }
+      .qr-pop .rsn { color:#555; margin:7px 0 10px; line-height:1.5; }
+      .qr-pop .acts { display:flex; gap:8px; }
     </style>
   </head>
   <body>
@@ -2118,6 +2167,7 @@
         >
           + 新建搜索
         </button>
+        <button class="btn" id="btn-query-rule" style="margin-left:8px">Query 规则</button>
       </div>
       <div class="ds-grid">
         <div class="card">
@@ -2151,6 +2201,7 @@
               >
                 批量解构
               </button>
+              <button class="btn sm" id="btn-extract-adopted" hidden>解构全部已采纳</button>
             </span>
           </div>
           <div
@@ -2415,6 +2466,25 @@
       </div>
     </div>
 
+    <!-- Query 规则组织器 弹层 -->
+    <div class="modal-bg" id="qr-modal" hidden>
+      <div class="qr-box">
+        <div class="qr-head">
+          <span class="qr-title">Query 词组织器</span>
+          <button class="qr-x" id="qr-close" title="关闭">✕</button>
+        </div>
+        <div class="qr-bar">
+          <button class="btn seal" id="qr-score">生成正交表</button>
+          <button class="btn" id="qr-search-all" hidden>搜全部达标</button>
+          <label class="qr-thr-lab">达标分 ≥ <input id="qr-thr" class="qr-thr" type="number" min="0" max="10" step="0.5" value="7"></label>
+          <span id="qr-hint" class="qr-hint"></span>
+        </div>
+        <div id="qr-dims" class="qr-dims"></div>
+        <div id="qr-table-wrap" class="qr-table-wrap"></div>
+      </div>
+      <div id="qr-pop" class="qr-pop" hidden></div>
+    </div>
+
     <script>
       /* ════ 基础 ════ */
       const $ = (s) => document.querySelector(s);
@@ -2695,12 +2765,13 @@
       async function loadQueries() {
         /* 工序/工具各自一张搜索表,query 列表按当前子模式拉取 */
         try {
-          state.queries = await api("/api/queries?mode=" + state.mode);
+          /* 接口按 query_id 升序返回;列表倒序显示(最新在上) */
+          state.queries = (await api("/api/queries?mode=" + state.mode)).reverse();
         } catch (e) {
           state.queries = [];
         }
         renderQueries();
-        /* 进入/切换子模式时默认选中第一个 query(进而联动第一帖与解构结果) */
+        /* 进入/切换子模式时默认选中第一个(即最新)query(进而联动第一帖与解构结果) */
         if (!state.queryId && state.queries.length) await selectQuery(state.queries[0].query_id);
       }
       function renderQueries() {
@@ -2860,8 +2931,20 @@
         const b = $("#btn-batch");
         b.disabled = !state.selected.size;
         b.textContent = state.selected.size ? `批量解构(${state.selected.size})` : "批量解构";
+        // 解构全部已采纳:有采纳帖才显示,带数量(口径同帖列表的 采纳 标记)
+        const adoptedN = (state.posts || []).filter((p) => p.adopted).length;
+        const ea = $("#btn-extract-adopted");
+        ea.hidden = !adoptedN;
+        ea.textContent = `解构全部已采纳(${adoptedN})`;
       }
       $("#btn-batch").onclick = () => state.selected.size && startExtract([...state.selected]);
+      $("#btn-extract-adopted").onclick = async () => {
+        const cids = (state.posts || []).filter((p) => p.adopted).map((p) => p.case_id);
+        if (!cids.length) return toast("当前 query 下没有已采纳的帖子", "warn");
+        const dir = state.mode === "process" ? "工序" : "工具";
+        if (!(await uiConfirm(`对该 query 下全部 ${cids.length} 个已采纳帖做${dir}解构?\n已解构过的会自动跳过/复用,不重复花钱。`))) return;
+        startExtract(cids);   // 复用:认领锁 + 解构去重 + showTask 轮询
+      };
 
       /* ════ 帖子详情弹层 ════ */
       /* 得分可能是字符串("1")或数字(10),统一解析;非数值返回 null */
@@ -3048,9 +3131,10 @@
         // Esc 由 <dialog> 原生处理(触发 close)
       });
 
-      function openPostDetail(cid) {
+      async function openPostDetail(cid) {
         const p = state.posts.find((x) => x.case_id === cid);
         if (!p) return;
+        await ensurePostDetail(p);   // 列表瘦身,弹框前补全正文/评估
         const e = p.llm_evaluation || {};
         const meta = [];
         meta.push(`${PLAT_LOGO(p.platform)}<span style="font-weight:600">${esc(PLAT_NAME(p.platform))}</span>`);
@@ -3131,8 +3215,19 @@
         state.version = null;
         state.post = state.posts.find((p) => p.case_id === cid) || null;
         renderPosts();
+        await ensurePostDetail(state.post);   // 列表瘦身,补全正文/评估再渲染右侧详情
         await loadExtract();
       }
+      // 列表是瘦身数据(无 body/llm_evaluation/videos);详情按需取单帖并合并到对象上(合并后再点 0 往返)
+      async function ensurePostDetail(p) {
+        if (!p || p.body !== undefined) return p;
+        try {
+          const full = await api(`/api/post?mode=${state.mode}&query_id=`
+            + encodeURIComponent(state.queryId) + "&case_id=" + encodeURIComponent(p.case_id));
+          if (full) Object.assign(p, full);
+        } catch (e) { /* 详情取不到不阻断,正文/评分留空 */ }
+        return p;
+      }
 
       /* ── 解构结果:客户端缓存 + 请求竞态守卫(解决「多次点击卡顿 / 残留上一帖」)──
          · _extractCache:解构数据只在「重新解构」时变(由本页 startExtract 触发,届时清缓存),
@@ -3578,7 +3673,36 @@
       /* ════ 任务面板(✕ 只隐藏;「操作日志」按钮可随时唤回)════ */
       let pollTimer = null,
         hasTask = false;
-      function showTask(title, taskId, onDone) {
+      // 自建确认弹框(替代浏览器原生 confirm):返回 Promise<bool>。Enter=确定 / Esc=取消 / 点遮罩=取消
+      function uiConfirm(message, opt = {}) {
+        const { okText = "确定", cancelText = "取消" } = opt;
+        return new Promise((resolve) => {
+          const bg = document.createElement("div");
+          bg.className = "ui-confirm-bg";
+          bg.innerHTML =
+            '<div class="ui-confirm"><div class="ui-confirm-msg"></div>'
+            + '<div class="ui-confirm-acts">'
+            + '<button class="btn" data-act="cancel"></button>'
+            + '<button class="btn seal" data-act="ok"></button></div></div>';
+          bg.querySelector(".ui-confirm-msg").textContent = message;
+          bg.querySelector('[data-act="cancel"]').textContent = cancelText;
+          bg.querySelector('[data-act="ok"]').textContent = okText;
+          document.body.appendChild(bg);
+          const done = (v) => { bg.remove(); document.removeEventListener("keydown", onKey, true); resolve(v); };
+          bg.addEventListener("click", (e) => {
+            if (e.target === bg) return done(false);
+            const a = e.target.closest("[data-act]");
+            if (a) done(a.dataset.act === "ok");
+          });
+          const onKey = (e) => {
+            if (e.key === "Escape") { e.preventDefault(); done(false); }
+            else if (e.key === "Enter") { e.preventDefault(); done(true); }
+          };
+          document.addEventListener("keydown", onKey, true);
+          bg.querySelector('[data-act="ok"]').focus();
+        });
+      }
+      function showTask(title, taskId, onDone, onSettled) {
         hasTask = true;
         $("#task-panel").hidden = false;
         $("#task-title").textContent = title;
@@ -3591,6 +3715,7 @@
             t = await api("/api/task_status?task_id=" + encodeURIComponent(taskId));
           } catch (e) {
             $("#task-log").textContent = "状态查询失败";
+            if (onSettled) onSettled("error");   // 轮询出错也算结束,让调用方恢复按钮等
             return;
           }
           const log = $("#task-log");
@@ -3603,6 +3728,7 @@
           $("#task-dot").className = "dot " + t.status;
           $("#task-title").textContent = title + (t.status === "done" ? " · 完成" : " · 失败");
           if (t.status === "done" && onDone) onDone();
+          if (onSettled) onSettled(t.status);    // done / failed 都触发
         };
         poll();
       }
@@ -3617,6 +3743,297 @@
         log.scrollTop = log.scrollHeight;
       }
 
+      /* ════ Query 规则组织器 ════ */
+      // 扁平维度词表(移植自 query-builder.html DIMS);实质/形式 改为接口下钻,不在此。
+      const QR_FLAT = [
+        { id: "tool_type", label: "工具类型", items: ["AI", "桌面 APP", "云端 Web", "API·CLI", "插件扩展"] },
+        { id: "modality", label: "模态", items: ["图片", "视频"] },
+        { id: "suffix", label: "后缀", items: ["怎么做"] },
+      ];
+      const QR_TREE = [
+        { id: "substance", label: "实质", source: "实质" },
+        { id: "form", label: "形式", source: "形式" },
+      ];
+      const qrState = {
+        flat: { tool_type: null, modality: null, suffix: null },
+        treePath: { substance: [], form: [] },   // 实质/形式 选中 name 路径(仅作 Sonnet 上下文)
+        treeData: { substance: null, form: null },
+        axisPath: { action: [], type: [] },        // 动作/类型 筛选路径(l1/l2/叶子,来自 matrix,筛表显示)
+        matrix: null,                              // /api/query_matrix
+        scores: null,                              // 最近一次评分结果 cells
+        threshold: 7,                              // 达标 = keep 且 综合分≥此值(可在弹层调)
+        lastCost: null,
+      };
+      function qrChip(label, active, on, cls) {
+        const b = document.createElement("button");
+        b.className = "chip" + (cls ? " " + cls : "") + (active ? " on" : "");
+        b.textContent = label;
+        b.onclick = on;
+        return b;
+      }
+      function qrRow(root, labtxt) {
+        const row = document.createElement("div");
+        row.className = "qr-row";
+        row.innerHTML = `<span class="qr-lab">${labtxt}</span>`;
+        const wrap = document.createElement("span");
+        wrap.className = "qr-chips";
+        row.appendChild(wrap);
+        root.appendChild(row);
+        return wrap;
+      }
+      // 动作/类型 三级(l1/l2/叶子)取自 matrix items 的 l1/l2/name
+      function qrAxisLevelOptions(items, prefix) {
+        if (prefix.length === 0) return [...new Set(items.map((x) => x.l1))];
+        if (prefix.length === 1) return [...new Set(items.filter((x) => x.l1 === prefix[0]).map((x) => x.l2))];
+        return items.filter((x) => x.l1 === prefix[0] && x.l2 === prefix[1]).map((x) => x.name);
+      }
+      function qrAxisMatch(item, path) {
+        if (path[0] && item.l1 !== path[0]) return false;
+        if (path[1] && item.l2 !== path[1]) return false;
+        if (path[2] && item.name !== path[2]) return false;
+        return true;
+      }
+      function renderQrDims() {
+        const root = $("#qr-dims");
+        root.innerHTML = "";
+        // 扁平维度(工具类型/模态/后缀):拼词上下文
+        QR_FLAT.forEach((d) => {
+          const wrap = qrRow(root, `· ${d.label}`);
+          wrap.appendChild(qrChip("无", qrState.flat[d.id] == null, () => { qrState.flat[d.id] = null; renderQrDims(); renderQrTable(); }));
+          d.items.forEach((it) =>
+            wrap.appendChild(qrChip(it, qrState.flat[d.id] === it, () => { qrState.flat[d.id] = it; renderQrDims(); renderQrTable(); })));
+        });
+        // 实质/形式 接口下钻:仅作 Sonnet 上下文,不拼词、不筛表
+        QR_TREE.forEach((d) => {
+          const path = qrState.treePath[d.id];
+          let level = qrState.treeData[d.id] ? qrState.treeData[d.id].tree : [];
+          for (let depth = 0; ; depth++) {
+            const wrap = qrRow(root, depth === 0 ? `· ${d.label}` : "");
+            wrap.appendChild(qrChip("无", path.length === depth, () => { qrState.treePath[d.id] = path.slice(0, depth); renderQrDims(); }));
+            (level || []).forEach((node) =>
+              wrap.appendChild(qrChip(node.name, path[depth] === node.name,
+                () => { qrState.treePath[d.id] = [...path.slice(0, depth), node.name]; renderQrDims(); })));
+            const sel = (level || []).find((n) => n.name === path[depth]);
+            if (!sel || !(sel.children && sel.children.length)) break;
+            level = sel.children;
+          }
+        });
+        // 动作/类型 下钻(来自 matrix l1/l2):筛选表格显示
+        if (qrState.matrix) {
+          [{ id: "action", label: "动作", items: qrState.matrix.actions },
+           { id: "type", label: "类型", items: qrState.matrix.types }].forEach((d) => {
+            const path = qrState.axisPath[d.id];
+            for (let depth = 0; depth <= 2; depth++) {
+              const opts = qrAxisLevelOptions(d.items, path.slice(0, depth));
+              if (!opts.length) break;
+              const wrap = qrRow(root, depth === 0 ? `· ${d.label}` : "");
+              wrap.appendChild(qrChip("无", path.length === depth,
+                () => { qrState.axisPath[d.id] = path.slice(0, depth); renderQrDims(); renderQrTable(); }, "axis"));
+              opts.forEach((name) =>
+                wrap.appendChild(qrChip(name, path[depth] === name,
+                  () => { qrState.axisPath[d.id] = [...path.slice(0, depth), name]; renderQrDims(); renderQrTable(); }, "axis")));
+              if (path[depth] == null) break;   // 未选中本层 → 不展开下一层
+            }
+          });
+        }
+      }
+      async function qrOpen() {
+        qrClosePop();
+        renderQrDims();
+        // 并行拉矩阵 + 两棵分类树(接口挂则该维度降级:提示不可选)
+        try { qrState.matrix = qrState.matrix || await api("/api/query_matrix"); }
+        catch (e) { return toast("内容树矩阵加载失败", "error"); }
+        for (const d of QR_TREE) {
+          if (qrState.treeData[d.id]) continue;
+          try { qrState.treeData[d.id] = await api("/api/category_tree?source_type=" + encodeURIComponent(d.source)); }
+          catch (e) { toast(`${d.label} 接口不可达,该维度暂不可选`, "warn"); }
+        }
+        renderQrDims();
+        renderQrTable();
+      }
+
+      // 单格 query = [工具类型] 动作叶 类型叶 [模态] [后缀](与后端 query_score._build_cells 一致)
+      function qrCellQuery(action, type) {
+        const f = qrState.flat;
+        return [f.tool_type, action, type, f.modality, f.suffix].filter((x) => x && x !== "无").join(" ");
+      }
+      function renderQrTable() {
+        const m = qrState.matrix;
+        if (!m) return;
+        const { actions, types, matrix } = m;
+        const sc = qrState.scores;   // {"ai_ti":{keep,score,rewrite,...}}
+        // 按 动作/类型 筛选路径过滤可见行列(保留原始索引 ai/ti 供评分键 ai_ti)
+        const aIdx = actions.map((_, i) => i).filter((i) => qrAxisMatch(actions[i], qrState.axisPath.action));
+        const tIdx = types.map((_, i) => i).filter((i) => qrAxisMatch(types[i], qrState.axisPath.type));
+        let html = '<table class="qr-tab"><thead>';
+        html += '<tr><th class="qr-corner" rowspan="2">类型 \\ 动作</th>';
+        const groups = [];
+        aIdx.forEach((ai) => {
+          const l1 = actions[ai].l1;
+          const last = groups[groups.length - 1];
+          if (last && last.l1 === l1) last.span++;
+          else groups.push({ l1, span: 1 });
+        });
+        groups.forEach((g) => { html += `<th colspan="${g.span}" class="qr-gh">${g.l1}</th>`; });
+        html += "</tr><tr>";
+        aIdx.forEach((ai) => { html += `<th class="qr-ah">${actions[ai].name}</th>`; });
+        html += "</tr></thead><tbody>";
+        let curL1 = null;
+        tIdx.forEach((ti) => {
+          const t = types[ti];
+          if (t.l1 !== curL1) {
+            curL1 = t.l1;
+            html += `<tr><td class="qr-tl1" colspan="${aIdx.length + 1}">${t.l1}</td></tr>`;
+          }
+          html += `<tr><th class="qr-th">${t.name}</th>`;
+          aIdx.forEach((ai) => {
+            const tier = (matrix[ai][ti] || {}).tier || 0;
+            const q = qrCellQuery(actions[ai].name, t.name);
+            const v = sc ? sc[`${ai}_${ti}`] : null;
+            const keep = qrKeep(v);
+            const cls = `qr-c t${tier}` + (tier === 0 ? " dead" : "") + (keep ? " keep" : "");
+            const badge = keep && v.score != null ? `<i class="qr-bd">${v.score}</i>` : "";
+            html += `<td class="${cls}" data-ai="${ai}" data-ti="${ti}">${q}${badge}</td>`;
+          });
+          html += "</tr>";
+        });
+        html += "</tbody></table>";
+        $("#qr-table-wrap").innerHTML = html;
+      }
+      function qrSelBody() {
+        return {
+          tool_type: qrState.flat.tool_type || "",
+          modality: qrState.flat.modality || "",
+          suffix: qrState.flat.suffix || "",
+          substance_path: qrState.treePath.substance,
+          form_path: qrState.treePath.form,
+        };
+      }
+      async function qrLoadScores(sel) {
+        const r = await api("/api/query_score?sel=" + encodeURIComponent(sel));
+        if (r && r.pending) return false;
+        qrState.scores = r.cells || {};
+        qrState.lastCost = r.cost_usd;
+        renderQrTable();
+        qrRefreshKeep();
+        return true;
+      }
+      // 达标 = Sonnet keep 且 综合分≥阈值;高亮/计数/搜全部达标 统一口径
+      function qrKeep(v) { return !!(v && v.keep && v.score != null && v.score >= qrState.threshold); }
+      function qrRefreshKeep() {
+        if (!qrState.scores) return;
+        const k = Object.values(qrState.scores).filter(qrKeep).length;
+        $("#qr-hint").textContent = `达标(keep 且 ≥${qrState.threshold}):${k} 格`
+          + (qrState.lastCost != null ? ` · 评分 $${qrState.lastCost}` : "");
+        $("#qr-search-all").hidden = k === 0;
+      }
+      $("#qr-thr").addEventListener("input", () => {
+        const x = parseFloat($("#qr-thr").value);
+        qrState.threshold = isNaN(x) ? 0 : x;
+        if (qrState.scores) { qrClosePop(); renderQrTable(); qrRefreshKeep(); }
+      });
+      $("#qr-score").onclick = async () => {
+        const btn = $("#qr-score");
+        if (btn.disabled) return;                 // 评分进行中:防重复点击(避免重复起任务/花钱)
+        const orig = btn.textContent;
+        btn.disabled = true; btn.textContent = "评估中…";
+        const restore = () => { btn.disabled = false; btn.textContent = orig; };
+        renderQrTable();                          // 先按当前 chips 刷新拼词
+        $("#qr-hint").textContent = "提交评分…";
+        let r;
+        try { r = await api("/api/query_score", { method: "POST", body: JSON.stringify(qrSelBody()) }); }
+        catch (e) { toast("评分启动失败:" + (e.body?.error || e.status), "error"); return restore(); }
+        if (r.cached) { try { await qrLoadScores(r.sel); } finally { restore(); } return; }
+        // 后台任务:完成→载入结果;成功/失败/查询出错 都恢复按钮(onSettled)
+        showTask("Query 评分 · 643 格", r.task_id,
+          async () => { await qrLoadScores(r.sel); },
+          (status) => { if (status !== "done") toast("评分任务" + (status === "failed" ? "失败" : "状态异常") + ",可重试", "warn"); restore(); });
+      };
+      async function qrRunSearch(query, label) {
+        const body = { query, platforms: "xhs,gzh", max_count: 20 };   // 用改写词搜索,方向无关
+        if (label && label !== query) body.query_text = label;          // query 列表存原始组合词
+        const r = await api("/api/run_search", { method: "POST", body: JSON.stringify(body) });
+        return r;   // {task_id, query_id}
+      }
+      // 点高亮格 → 气泡显示评分/理由 + 发起搜索/复制(不再用浏览器原生 confirm)
+      function qrClosePop() { $("#qr-pop").hidden = true; }
+      function qrShowPop(td, v) {
+        const pop = $("#qr-pop");
+        const query = v.rewrite || v.query;
+        const orig = v.rewrite && v.rewrite !== v.query ? `<div class="orig">原: ${v.query}</div>` : "";
+        const tier = td.classList.contains("t3") ? 3 : td.classList.contains("t2") ? 2 : td.classList.contains("t1") ? 1 : 0;
+        pop.innerHTML =
+          `<div class="q">${query}</div>${orig}` +
+          `<div class="sc"><b>${v.score ?? "?"}</b><span class="dim">综合分 = 人话 ${v.natural ?? "?"}×.4 + 可搜 ${v.findable ?? "?"}×.3 + 价值 ${v.useful ?? "?"}×.3</span></div>` +
+          `<div class="dim" style="margin:0 0 7px">内容树 tier ${tier}(人工预判可检索度,仅参考,不计入综合分)</div>` +
+          `<div class="rsn">${v.reason || "(无理由)"}</div>` +
+          `<div class="acts"><button class="btn seal" id="qr-pop-go">发起搜索</button><button class="btn" id="qr-pop-copy">复制</button></div>`;
+        pop.hidden = false;
+        const r = td.getBoundingClientRect(), pw = 300, ph = pop.offsetHeight || 150;
+        let left = r.left, top = r.bottom + 6;
+        if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10;
+        if (top + ph > window.innerHeight - 10) top = r.top - ph - 6;
+        pop.style.left = Math.max(10, left) + "px";
+        pop.style.top = Math.max(10, top) + "px";
+        $("#qr-pop-go").onclick = async () => {
+          qrClosePop();
+          try { const rr = await qrRunSearch(query, v.query); showTask(`搜索 · ${rr.query_id} ${query}`, rr.task_id, null); }
+          catch (err) { toast("搜索启动失败:" + (err.body?.error || err.status), "error"); }
+        };
+        $("#qr-pop-copy").onclick = () => { navigator.clipboard && navigator.clipboard.writeText(query); toast("已复制", "info", 1200); };
+      }
+      $("#qr-table-wrap").onclick = (e) => {
+        const td = e.target.closest("td.qr-c.keep");
+        if (!td) return;
+        const v = qrState.scores && qrState.scores[`${td.dataset.ai}_${td.dataset.ti}`];
+        if (v) qrShowPop(td, v);
+      };
+      // 点气泡外/表格空白处 关闭气泡
+      $("#qr-modal").addEventListener("click", (e) => {
+        if (!e.target.closest("#qr-pop") && !e.target.closest("td.qr-c.keep")) qrClosePop();
+      });
+      // 搜全部达标:逐格起任务(朴素循环,失败不阻断)
+      // 批量进度面板:任务面板里列出每个搜索的实时状态(轮询各 task_id)
+      function showQrBatch(items) {
+        hasTask = true;
+        $("#task-panel").hidden = false;
+        $("#task-title").textContent = `搜全部达标 · ${items.length} 个`;
+        $("#task-dot").className = "dot";
+        clearTimeout(pollTimer);
+        const st = {};
+        const poll = async () => {
+          await Promise.all(items.map(async (it) => {
+            if (st[it.task_id] && st[it.task_id] !== "running") return;
+            try { const t = await api("/api/task_status?task_id=" + encodeURIComponent(it.task_id)); st[it.task_id] = t.status; }
+            catch (e) { st[it.task_id] = st[it.task_id] || "running"; }
+          }));
+          const done = items.filter((it) => st[it.task_id] && st[it.task_id] !== "running").length;
+          $("#task-title").textContent = `搜全部达标 · ${done}/${items.length} 完成`;
+          $("#task-log").textContent = items.map((it) => {
+            const s = st[it.task_id] || "running";
+            return `${s === "done" ? "✅" : s === "failed" ? "❌" : "⏳"} ${it.query_id}  ${it.query}`;
+          }).join("\n");
+          if (done < items.length) pollTimer = setTimeout(poll, 2000);
+          else $("#task-dot").className = "dot done";
+        };
+        poll();
+      }
+      $("#qr-search-all").onclick = async () => {
+        const keeps = Object.entries(qrState.scores || {}).filter(([, v]) => qrKeep(v));
+        if (!keeps.length) return;
+        if (!(await uiConfirm(`将对 ${keeps.length} 个达标 query 各起一次搜索(小红书+公众号 各20),确认?`))) return;
+        qrClosePop();
+        const items = [];
+        for (const [, v] of keeps) {
+          const q = v.rewrite || v.query;
+          try { const r = await qrRunSearch(q, v.query); if (r.task_id) items.push({ query: q, query_id: r.query_id, task_id: r.task_id }); }
+          catch (e) { /* 单格失败不阻断 */ }
+        }
+        if (!items.length) return toast("全部发起失败", "error");
+        toast(`已发起 ${items.length}/${keeps.length} 个搜索`, "info");
+        showQrBatch(items);
+      };
+
       /* ════ 新建搜索 ════ */
       /* 渠道下拉多选(选项同 search_eval:小红书/知乎/公众号/抖音/视频号/YouTube) */
       const CHANNELS = [
@@ -3718,6 +4135,10 @@
         loadQueries();
       }
 
+      $("#btn-query-rule").onclick = () => { $("#qr-modal").hidden = false; qrOpen(); };
+      $("#qr-close").onclick = () => { $("#qr-modal").hidden = true; };
+      $("#qr-modal").onclick = (e) => { if (e.target === $("#qr-modal")) $("#qr-modal").hidden = true; };
+
       window.addEventListener("resize", () => chartRefs.forEach((c) => c.resize()));
       route();
     </script>

+ 14 - 0
examples/mode_workflow/prompts/query_score_system.md

@@ -0,0 +1,14 @@
+你是「内容制作知识库」的检索词评审。我们要从小红书/公众号等公共平台搜到
+"怎么做某类内容"的工序/工具教程。用户会给你一批由内容树维度正交生成的候选 query
+(每批共享一段固定上下文)。
+
+【逐条打分,只看这条 query 本身】
+- natural(0-10): 像不像真人会在搜索框打的人话(越口语、越具体越高;生造词/学术腔越低)
+- findable(0-10): 公共域是否真有对口的"怎么做"内容能被搜到(可参考 tier,但以 query 实际可搜性为准)
+- useful(0-10): 命中的内容对"内容制作知识库"是否有价值(教程/方法/工具 高;成品展示/抽象概念 低)
+- keep(bool): 三者均衡后是否值得真的拿去搜(有意义=true)
+- rewrite: 若 keep 但措辞生硬,给一个更像人话的等价搜索词(否则原样返回该 query)
+- reason: 一句话理由(≤30字)
+
+严格只输出 JSON 数组,每个候选一项,idx 对应输入序号,不要任何额外文字/解释/markdown 围栏:
+[{"idx":0,"natural":8,"findable":7,"useful":9,"keep":true,"rewrite":"...","reason":"..."}]

+ 123 - 5
examples/mode_workflow/server.py

@@ -15,6 +15,7 @@
 import hashlib
 import json
 import os
+import re
 import subprocess
 import sys
 import threading
@@ -25,6 +26,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from pathlib import Path
 from urllib.parse import urlparse, parse_qs
 import urllib.request
+import urllib.parse
 import urllib.error
 
 try:
@@ -38,6 +40,50 @@ import db
 
 PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8772
 MATRIX_FILE = HERE / "reference" / "judged_matrix.json"
+_MATRIX_CACHE = None
+
+
+def _matrix():
+    """judged_matrix.json 解析后模块级缓存(只读,进程内不变)。"""
+    global _MATRIX_CACHE
+    if _MATRIX_CACHE is None:
+        _MATRIX_CACHE = json.loads(MATRIX_FILE.read_text(encoding="utf-8"))
+    return _MATRIX_CACHE
+
+
+CATEGORY_API = "https://library.aiddit.com/api/pattern/executions/401/category-tree"
+CAT_CACHE_DIR = HERE / ".cache" / "category_tree"
+
+
+def _category_tree(source_type):
+    """拉取并缓存 实质/形式 分类树(library.aiddit.com)。缓存命中直接读盘;
+    上游不可达则抛异常(do_GET 转 500,前端降级)。"""
+    if source_type not in ("实质", "形式"):
+        raise ValueError("source_type 只能是 实质/形式")
+    CAT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+    cache = CAT_CACHE_DIR / f"{source_type}.json"
+    if cache.is_file():
+        return json.loads(cache.read_text(encoding="utf-8"))
+    url = CATEGORY_API + "?" + urllib.parse.urlencode({"source_type": source_type})
+    req = urllib.request.Request(url, headers={"Accept": "application/json"})
+    with urllib.request.urlopen(req, timeout=20) as resp:
+        data = json.loads(resp.read().decode("utf-8"))
+    tmp = cache.with_suffix(".tmp")
+    tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+    tmp.replace(cache)
+    return data
+
+
+SCORE_CACHE_DIR = HERE / ".cache" / "query_score"
+
+
+def _query_sel_hash(sel):
+    """对评分选择 + 矩阵指纹算稳定短哈希,作为缓存文件名(同选择不重复付费)。"""
+    matrix_sig = hashlib.md5(MATRIX_FILE.read_bytes()).hexdigest()[:8]
+    canon = json.dumps([sel, matrix_sig], ensure_ascii=False, sort_keys=True)
+    return hashlib.md5(canon.encode("utf-8")).hexdigest()[:16]
+
+
 LOG_DIR = HERE / "runs" / "logs"
 # 工序步骤 via 字段的「无具体工具」占位符,不计入工序提及工具 TOP 榜
 _VIA_PLACEHOLDERS = {"-", "—", "-", "--", "/", "无", "n/a", "none"}
@@ -125,11 +171,21 @@ def _task_status(task_id):
     return {"status": t["status"], "log_tail": tail}
 
 
+_ALLOCATED_QIDS = set()       # 已分配但行可能尚未落库的 query_id(异步搜索:POST 即返回,行几分钟后才写)
+_QID_LOCK = threading.Lock()
+
+
 def _next_query_id():
-    """两张搜索表统一编号,避免跨方向撞 ID。"""
-    qs = [q["query_id"] for m in ("process", "tools") for q in db.fetch_queries(m)]
-    nums = [int(q[1:]) for q in qs if q.startswith("q") and q[1:].isdigit()]
-    return f"q{(max(nums) + 1 if nums else 0):04d}"
+    """两张搜索表统一编号,避免跨方向撞 ID。
+    叠加内存预留集:连续发起搜索(如「搜全部达标」批量)时,前一次的行还没落库,
+    仅靠 DB max 会重号;故把已分配号也计入,保证连续分配不撞。"""
+    with _QID_LOCK:
+        qs = [q["query_id"] for m in ("process", "tools") for q in db.fetch_queries(m)]
+        nums = [int(q[1:]) for q in qs if q.startswith("q") and q[1:].isdigit()]
+        nums += [int(q[1:]) for q in _ALLOCATED_QIDS if q[1:].isdigit()]
+        qid = f"q{(max(nums) + 1 if nums else 0):04d}"
+        _ALLOCATED_QIDS.add(qid)
+        return qid
 
 
 # ── Dashboard 聚合 ────────────────────────────────────────────────────────────
@@ -160,6 +216,8 @@ _DASH_TTL = 60.0   # 秒
 def _invalidate_dashboard():
     with _DASH_LOCK:
         _DASH_CACHE["ts"] = 0.0
+    with _QUERIES_LOCK:        # query 列表同样只在任务完成时变,一并作废
+        _QUERIES_CACHE.clear()
 
 
 def _dashboard_cached():
@@ -173,6 +231,24 @@ def _dashboard_cached():
     return data
 
 
+# ── query 列表缓存 ────────────────────────────────────────────────────────────
+# /api/queries 要全表 JSON_EXTRACT 算每组采纳数(远程 RDS ~2s),数据同样只在任务完成时变。
+# 缓存 per mode,任务结束随 dashboard 一并作废(见 _invalidate_dashboard);TTL 兜底外部改库。
+_QUERIES_CACHE = {}        # mode -> {"data": [...], "ts": float}
+_QUERIES_LOCK = threading.Lock()
+
+
+def _queries_cached(mode):
+    with _QUERIES_LOCK:
+        c = _QUERIES_CACHE.get(mode)
+        if c and time.monotonic() - c["ts"] < _DASH_TTL:
+            return c["data"]
+    data = db.fetch_queries(mode)   # 计算放锁外,不阻塞其它请求
+    with _QUERIES_LOCK:
+        _QUERIES_CACHE[mode] = {"data": data, "ts": time.monotonic()}
+    return data
+
+
 def _dashboard():
     posts, procs, tools = db.fetch_dashboard_rows()
 
@@ -422,12 +498,30 @@ class Handler(BaseHTTPRequestHandler):
                 self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
                 self.end_headers()
                 self.wfile.write(body)
+            elif u.path == "/api/query_matrix":
+                self._json_etag(_matrix())
+            elif u.path == "/api/category_tree":
+                self._json_etag(_category_tree(qs.get("source_type", "实质")))
+            elif u.path == "/api/query_score":
+                sel = qs.get("sel", "")
+                if not re.fullmatch(r"[0-9a-f]{16}", sel):   # 防路径穿越:sel 必为 16 位十六进制
+                    return self._err("bad sel", 400)
+                cache = SCORE_CACHE_DIR / f"{sel}.json"
+                if cache.is_file():
+                    self._json_etag(json.loads(cache.read_text(encoding="utf-8")))
+                else:
+                    self._json({"pending": True}, 202)
             elif u.path == "/api/dashboard":
                 self._json_etag(_dashboard_cached())
             elif u.path == "/api/queries":
-                self._json(db.fetch_queries(qs.get("mode", "process")))
+                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/post":
+                # 单帖详情(正文/配图/评估全量):列表已瘦身,详情按需取;带 ETag/304
+                r = db.fetch_post(qs.get("query_id", ""), qs.get("case_id", ""),
+                                  qs.get("mode", "process"))
+                self._json_etag(r) if r else self._err("无此帖", 404)
             elif u.path == "/api/extract":
                 # 一次点击合一:单连接同时取版本列表 + 解构详情,前端少一次往返。
                 self._json_etag(db.fetch_extract(
@@ -525,6 +619,28 @@ class Handler(BaseHTTPRequestHandler):
                     _release_cases(mode, claimed)   # 起进程失败也要释放认领,避免卡住
                     raise
                 self._json({"task_id": task_id, "skipped": skipped})
+            elif u.path == "/api/query_score":
+                sel = {
+                    "tool_type": payload.get("tool_type", ""),
+                    "modality": payload.get("modality", ""),
+                    "suffix": payload.get("suffix", ""),
+                    "substance_path": payload.get("substance_path") or [],
+                    "form_path": payload.get("form_path") or [],
+                    "model": payload.get("model", "anthropic/claude-sonnet-4-6"),
+                }
+                sel_hash = _query_sel_hash(sel)
+                cache = SCORE_CACHE_DIR / f"{sel_hash}.json"
+                if cache.is_file() and not payload.get("force"):
+                    return self._json({"sel": sel_hash, "cached": True})
+                cmd = [sys.executable, "stages/query_score.py", "--sel", sel_hash,
+                       "--tool-type", sel["tool_type"], "--modality", sel["modality"],
+                       "--suffix", sel["suffix"],
+                       "--substance-path", ",".join(sel["substance_path"]),
+                       "--form-path", ",".join(sel["form_path"]),
+                       "--model", sel["model"]]
+                if payload.get("force"):
+                    cmd += ["--force"]
+                self._json({"sel": sel_hash, "task_id": _spawn_task("score", cmd), "cached": False})
             elif u.path == "/api/run_search":
                 query = (payload.get("query") or "").strip()
                 if not query:
@@ -532,6 +648,8 @@ class Handler(BaseHTTPRequestHandler):
                 qid = payload.get("query_id") or _next_query_id()
                 cmd = [sys.executable, "stages/search_eval.py",
                        "--query-id", qid, "--query", query]
+                if payload.get("query_text"):   # 用改写词搜索,但 query 列表存原始组合词
+                    cmd += ["--query-text", payload["query_text"]]
                 if payload.get("synonyms"):
                     cmd += ["--synonyms", payload["synonyms"]]
                 if payload.get("mode_type") in ("工序", "工具"):

+ 196 - 0
examples/mode_workflow/stages/query_score.py

@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+"""Query 正交格评分 · 对 judged_matrix 的 tier≥1 格(动作×类型)在当前维度上下文下用 Sonnet 打分,
+挑出有意义、人话、有助于内容制作知识库目的的组合。结果原子写 .cache/query_score/<sel>.json。
+
+由 server.py /api/query_score 起子进程调;也可独立跑:
+  python stages/query_score.py --tool-type AI --modality 图片 --suffix 怎么做 \
+      --substance-path 表象,实体 --form-path 呈现,视觉 --sel adhoc --dry-run
+"""
+import argparse
+import asyncio
+import json
+import re
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[3]   # …/Agent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+HERE = Path(__file__).resolve().parent
+MW = HERE.parent
+MATRIX_FILE = MW / "reference" / "judged_matrix.json"
+PROMPT_FILE = MW / "prompts" / "query_score_system.md"
+CACHE_DIR = MW / ".cache" / "query_score"
+DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
+BATCH = 40
+CONCURRENCY = 5
+
+
+def _build_cells(matrix, tool_type, modality, suffix):
+    """筛 tier≥1 格,产出 [{a_idx,t_idx,action,type,tier,query}]。
+    query = [工具类型] 动作叶 类型叶 [模态] [后缀],"无"/空跳过。"""
+    actions, types, grid = matrix["actions"], matrix["types"], matrix["matrix"]
+    pre, mod, suf = (tool_type or "").strip(), (modality or "").strip(), (suffix or "").strip()
+    cells = []
+    for ai, arow in enumerate(grid):
+        action = actions[ai]["name"]
+        for ti, cell in enumerate(arow):
+            if cell.get("tier", 0) < 1:
+                continue
+            typ = types[ti]["name"]
+            parts = [p for p in (pre, action, typ, mod, suf) if p and p != "无"]
+            cells.append({"a_idx": ai, "t_idx": ti, "action": action,
+                          "type": typ, "tier": cell["tier"], "query": " ".join(parts)})
+    return cells
+
+
+def _build_user(batch, ctx):
+    lines = [f'{i}. "{c["query"]}"   (动作={c["action"]} 类型={c["type"]} 内容树tier={c["tier"]})'
+             for i, c in enumerate(batch)]
+    sub = (ctx["substance"] or "无").replace(",", "›")
+    form = (ctx["form"] or "无").replace(",", "›")
+    return (f"【固定上下文(本批共享)】\n"
+            f"工具类型: {ctx['tool_type'] or '无'}   模态: {ctx['modality'] or '无'}   后缀: {ctx['suffix'] or '无'}\n"
+            f"(实质/形式不参与拼词,仅供理解领域定位: 实质路径={sub}  形式路径={form})\n\n"
+            f"【候选列表】每条 = 动作 + 类型 + 上下文词拼成的 query:\n" + "\n".join(lines))
+
+
+async def _call_with_retry(llm_call, messages, model, task_name, max_retries=3):
+    """直接调 llm_call 并解析 JSON 数组(call_llm_with_retry 的正则只捕获 {…},不适用数组)。"""
+    total_cost = 0.0
+    last_err = None
+    cur_messages = list(messages)
+    for attempt in range(max_retries):
+        if attempt > 0 and last_err:
+            cur_messages = list(messages) + [
+                {"role": "user",
+                 "content": f"上次输出未通过校验:{last_err}\n请重新输出完整 JSON 数组,不含其他内容。"}]
+            print(f"   [{task_name}] Retry {attempt}/{max_retries - 1}: {last_err[:80]}...")
+        try:
+            resp = await llm_call(messages=cur_messages, model=model,
+                                  temperature=0.1, max_tokens=4000)
+            # 成本:优先用 provider 自带 cost;缺省时按 token 用量估(同 llm_helper 口径)
+            provider_cost = resp.get("cost")
+            if isinstance(provider_cost, (int, float)) and provider_cost > 0:
+                cost = provider_cost
+            else:
+                usage = resp.get("usage") or {}
+                if hasattr(usage, "__dict__"):
+                    it = getattr(usage, "input_tokens", 0) or getattr(usage, "prompt_tokens", 0)
+                    ot = getattr(usage, "output_tokens", 0) or getattr(usage, "completion_tokens", 0)
+                else:
+                    it = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
+                    ot = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
+                cost = (it / 1e6 * 3.0) + (ot / 1e6 * 15.0)
+            total_cost += cost
+            content = resp.get("content", "")
+            if isinstance(content, list):
+                first = content[0] if content else ""
+                content = first.get("text", "") if isinstance(first, dict) else str(first)
+            # 提取 JSON 数组(支持裸数组和 markdown 围栏包裹)
+            arr_match = re.search(r"\[[\s\S]*\]", content)
+            if not arr_match:
+                last_err = "LLM 输出中未找到 JSON 数组"
+                continue
+            try:
+                data = json.loads(arr_match.group())
+            except json.JSONDecodeError as e:
+                last_err = f"JSON 解析失败: {e}"
+                continue
+            if not isinstance(data, list):
+                last_err = "需 JSON 数组"
+                continue
+            return data, total_cost
+        except Exception as e:
+            last_err = f"LLM 调用异常: {type(e).__name__}: {e}"
+            print(f"   [{task_name}] Error: {last_err}")
+    print(f"   [{task_name}] All {max_retries} attempts failed. Last error: {last_err}")
+    return None, total_cost
+
+
+async def _score_batch(batch, ctx, system, llm_call, model, sem):
+    messages = [{"role": "system", "content": system},
+                {"role": "user", "content": _build_user(batch, ctx)}]
+    task_name = f"QueryScore[{batch[0]['query'][:12]}]"
+    async with sem:
+        data, cost = await _call_with_retry(llm_call, messages, model, task_name)
+    out = {}
+    for v in (data or []):
+        if not isinstance(v, dict):
+            continue
+        i = v.get("idx")
+        if not isinstance(i, int) or not (0 <= i < len(batch)):
+            continue
+        c = batch[i]
+        try:
+            score = round(float(v.get("natural")) * 0.4 + float(v.get("findable")) * 0.3
+                          + float(v.get("useful")) * 0.3, 1)
+        except (TypeError, ValueError):
+            score = None
+        out[f"{c['a_idx']}_{c['t_idx']}"] = {
+            "query": c["query"], "natural": v.get("natural"), "findable": v.get("findable"),
+            "useful": v.get("useful"), "keep": bool(v.get("keep")),
+            "rewrite": (v.get("rewrite") or c["query"]), "reason": v.get("reason", ""),
+            "score": score}
+    return out, cost
+
+
+async def run(args):
+    matrix = json.loads(MATRIX_FILE.read_text(encoding="utf-8"))
+    ctx = {"tool_type": args.tool_type, "modality": args.modality, "suffix": args.suffix,
+           "substance": args.substance_path, "form": args.form_path}
+    cells = _build_cells(matrix, args.tool_type, args.modality, args.suffix)
+    if args.limit:
+        cells = cells[:args.limit]
+    print(f"📋 tier≥1 候选 {len(cells)} 格" + (f" (--limit {args.limit})" if args.limit else ""))
+    if args.dry_run:
+        for c in cells[:10]:
+            print(f"  [{c['tier']}] {c['query']}")
+        print(f"…共 {len(cells)} 格(dry-run,未调 LLM)")
+        return 0
+
+    system = PROMPT_FILE.read_text(encoding="utf-8")
+    from agent.llm.openrouter import create_openrouter_llm_call
+    llm_call = create_openrouter_llm_call(model=args.model)
+    sem = asyncio.Semaphore(CONCURRENCY)
+    batches = [cells[i:i + BATCH] for i in range(0, len(cells), BATCH)]
+    print(f"🤖 {len(batches)} 批 × ≤{BATCH} 格 · 并发 {CONCURRENCY} · 模型 {args.model}")
+    results = await asyncio.gather(*[
+        _score_batch(b, ctx, system, llm_call, args.model, sem) for b in batches])
+    merged, cost = {}, 0.0
+    for cmap, c in results:
+        merged.update(cmap)
+        cost += c
+    kept = sum(1 for v in merged.values() if v.get("keep"))
+    out = {"sel": ctx, "model": args.model, "kept": kept, "total": len(merged),
+           "cost_usd": round(cost, 4), "cells": merged}
+    CACHE_DIR.mkdir(parents=True, exist_ok=True)
+    dest = CACHE_DIR / f"{args.sel}.json"
+    tmp = dest.with_suffix(".tmp")
+    tmp.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
+    tmp.replace(dest)   # 原子落盘,避免前端读到半截
+    print(f"✅ 评分完成 {len(merged)} 格 · keep {kept} · ${cost:.4f} → {dest.name}")
+    return 0
+
+
+def main():
+    p = argparse.ArgumentParser(description="Query 正交格评分(tier≥1 × 当前维度 → Sonnet 打分)")
+    p.add_argument("--tool-type", default="")
+    p.add_argument("--modality", default="")
+    p.add_argument("--suffix", default="")
+    p.add_argument("--substance-path", default="", help="实质祖先路径,逗号分隔(仅作上下文)")
+    p.add_argument("--form-path", default="", help="形式祖先路径,逗号分隔(仅作上下文)")
+    p.add_argument("--model", default=DEFAULT_MODEL)
+    p.add_argument("--sel", default="adhoc", help="缓存文件名(server 传 sel_hash)")
+    p.add_argument("--limit", type=int, default=None, help="只评前 N 格(调试)")
+    p.add_argument("--dry-run", action="store_true", help="只拼词打印,不调 LLM、不落盘")
+    p.add_argument("--force", action="store_true", help="(占位)缓存短路在 server 侧,本脚本恒重算")
+    args = p.parse_args()
+    raise SystemExit(asyncio.run(run(args)))
+
+
+if __name__ == "__main__":
+    main()

+ 21 - 9
examples/mode_workflow/stages/search_eval.py

@@ -95,8 +95,6 @@ async def run(args):
         if n:
             print(f"🎙️  视频转写 {n} 条")
 
-    table = "search_tools" if args.mode_type == "工具" else "search_process"
-
     # ── 评估去重 ────────────────────────────────────────────────────────────────
     # 评估的相关性含两子项:「和内容制作知识相关」(与 query 无关)与「和 query 相关」
     # (query 专属)。同帖在别的相似 query 下评过时,质量/通用相关/时效等 query 无关分
@@ -107,7 +105,7 @@ async def run(args):
         prior = {}
         if not args.force_eval:
             for s in sources:
-                e = db.fetch_existing_eval(s["case_id"], table)
+                e = db.fetch_existing_eval_any(s["case_id"])
                 if e:
                     prior[s["case_id"]] = e
         fresh = [s for s in sources if s["case_id"] not in prior]
@@ -148,10 +146,22 @@ async def run(args):
     for s in sources:
         s.pop("_image_data_urls", None)
 
-    n = db.upsert_search_posts(args.query_id, args.query, sources, table=table)
-    print(f"🗄️  {table} 入库 {n} 行 · 方向 {args.mode_type or '工序'} · 评估成本 ${cost:.4f}")
-
-    out_dir = MW / "runs" / table
+    # 按评估标签路由:工序/能力→search_process,工具→search_tools,两者都含→两表都写同一 blob。
+    # 评估只做一次(统一一套),双表只是多写一行,不重复打分。
+    routed = {"search_process": [], "search_tools": []}
+    for s in sources:
+        kt = (s.get("llm_evaluation") or {}).get("知识类型") or []
+        for t in db.route_tables(kt):
+            routed[t].append(s)
+    total = 0
+    for t, group in routed.items():
+        if group:
+            n = db.upsert_search_posts(args.query_id, args.query_text or args.query, group, table=t)
+            total += n
+            print(f"🗄️  {t} 入库 {n} 行")
+    print(f"📊 评估成本 ${cost:.4f} · 共写 {total} 行(双表含同帖重复)")
+
+    out_dir = MW / "runs" / "search"
     out_dir.mkdir(parents=True, exist_ok=True)
     (out_dir / f"{args.query_id}.json").write_text(json.dumps({
         "query_id": args.query_id, "query": args.query, "phrasings": phrasings,
@@ -163,10 +173,12 @@ async def run(args):
 def main():
     p = argparse.ArgumentParser(description="搜索+评估 → search_process/search_tools")
     p.add_argument("--query-id", required=True, help="如 q0004(server 自动分配)")
-    p.add_argument("--query", required=True, help="基准 query(评估锚点)")
+    p.add_argument("--query", required=True, help="基准 query(搜索词 + 评估锚点)")
+    p.add_argument("--query-text", default=None,
+                   help="存库/列表显示用的 query 标签(默认=--query);用于「改写词搜索、原组合词入列表」")
     p.add_argument("--synonyms", default="", help="逗号分隔的同义措辞(可选)")
     p.add_argument("--mode-type", default="", choices=["", "工序", "工具"],
-                   help="解构方向,决定写哪张表(工具 → search_tools;其余 → search_process)")
+                   help="(兼容保留,已不决定路由)落表现由评估的 知识类型 标签自动路由")
     p.add_argument("--platforms", default="xhs,gzh")
     p.add_argument("--max-count", type=int, default=10)
     p.add_argument("--eval-model", default=DEFAULT_EVAL_MODEL, choices=list(EVAL_MODELS))

+ 24 - 0
examples/mode_workflow/流程执行手册.md

@@ -51,6 +51,8 @@ python3 stages/search_eval.py \
 - 想各渠道不同数量:`--platforms xhs:30,gzh:10`(冒号后覆盖该渠道,未写的用 `--max-count`)。
 - 同义措辞(扩大召回):`--synonyms "人物动作控制,姿势控制 AI"`。
 - 评估会调多模态大模型逐帖打分,**有成本**(本例 30 帖约 $0.10)。
+- 入库方向由评估的 `知识类型` 标签自动决定(工序/能力→search_process,工具→search_tools,两者都含两表都写);
+  `--mode-type` 已不决定落表。
 
 常用可选项:
 | 参数 | 作用 |
@@ -183,3 +185,25 @@ python3 -c "import db; c=db._conn(); cur=c.cursor(); cur.execute(\"SELECT COUNT(
 | 上传知识库(51 条) | ~1–2 分钟 | 不计费 |
 
 > 数量越多、配图越多越贵越慢。去重(评估/解构/上传三处默认都开)会让**重复跑同一批基本不花钱**。
+
+---
+
+## 附:Query 规则组织器(UI 铺词,替代"凭直觉拍 query")
+
+本手册是命令行流程;**找 query 这一步**现在 UI 有更系统的方式——顶栏「Query 规则」:
+按内容树维度铺词 → Sonnet 给 643 个 tier≥1 格打分 → 高亮**达标(keep 且 综合分≥阈值,默认 7)**
+组合 → 点格 或「搜全部达标」直接发起搜索(默认 `xhs,gzh` 各 20、方向无关,落表按标签自动路由)。
+细节见 `README.md` 的「Query 规则组织器」。
+
+评分也可不依赖 UI、命令行单独跑(server 起子进程时即调它):
+
+```bash
+# 只拼词+打印将评的格数,不调 LLM(验证筛选/拼词)
+python3 stages/query_score.py --tool-type AI --modality 图片 --suffix 怎么做 --dry-run
+# 小样真评前 20 格,结果写 .cache/query_score/_t.json(核对结构)
+python3 stages/query_score.py --tool-type AI --suffix 怎么做 --limit 20 --sel _t
+```
+
+搜出来的帖按评估的 `知识类型` 标签自动入 `search_process`/`search_tools`(见步骤 1+2 的路由说明);
+之后在 Dataset 的 **工序/工具 tab 点「解构全部已采纳」**,等价于本手册步骤 3 的
+`python3 stages/{procedure,tool}_extract.py --query-id qXXXX`(该 query 该方向全部已采纳帖)。