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

refactor(eval-pipeline): 重构知识评估管线,兼容新旧格式并新增Prompt临时覆盖能力

- 移除三处废弃的「评论反馈」固定键映射,清理旧维度支持
- 为命令行工具新增`--prompt-file`参数,支持临时覆盖默认Prompt进行解构
- 在Web服务中新增`/api/extract_prompt`接口与Prompt编辑弹窗,支持自定义Prompt重新解构
- 重构评估管线的schema兼容逻辑:将可复现性判断从固定维度迁移至动态维度的实现完整性,新增`_repro_score`函数适配两种评估数据格式
- 优化`llm_evaluate_sources.py`代码,移除评论抽取逻辑,新增评估Schema校验逻辑确保必填字段完整
- 重构`search_and_evaluate.py`与`db.py`中的评估判定逻辑,新增`update_post_eval`函数同步更新派生字段
- 新增`eval_compare.py`评估对比工具、`_batch_reeval_q0020.py`批量重评脚本与`_reeval_one.py`持久化能力
- 更新Prompt模板文件,调整评估维度结构,完善Intent字段编写规范
- 优化前端界面样式与Intent字段渲染效果
刘文武 14 часов назад
Родитель
Сommit
c67f653b53

+ 141 - 0
examples/mode_workflow/_batch_reeval_q0020.py

@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+"""批量重评 q0000 下当前【命中(is_adopted)】的帖子,用 flash-lite+sonnet 组合(模糊带升级),
+跑完定向替换 DB 的得分相关字段(overall_score / knowledge_type / llm_evaluation)。
+先备份旧值到 runs/search_process/q0000.score_backup.<ts>.json,可回滚。"""
+import asyncio, copy, json, sys
+from datetime import datetime
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[3]
+sys.path.insert(0, str(PROJECT_ROOT))
+from dotenv import load_dotenv
+load_dotenv()
+
+MW = Path(__file__).resolve().parent
+sys.path.insert(0, str(MW))
+import db
+from examples.process_pipeline.script.search_eval.search_and_evaluate import evaluate_posts
+from examples.process_pipeline.script.llm_evaluate_sources import (
+    _EVAL_PRODUCT_FIELDS, build_eval_llm_call,
+)
+
+QUERY_ID = "q0020"
+TABLE = "search_process"
+INIT_MODEL = "gemini-flash-lite"
+ESC_MODEL = "sonnet"
+BAND = (4.0, 6.0)
+
+
+def _load_db_rows():
+    conn = db._conn()
+    try:
+        with conn.cursor() as c:
+            c.execute(f"SELECT case_id, overall_score, knowledge_type, publish_time, "
+                      f"llm_evaluation FROM {TABLE} WHERE query_id=%s", (QUERY_ID,))
+            return c.fetchall()
+    finally:
+        conn.close()
+
+
+def _update_scores(case_id, overall, knowledge_type, evaluation):
+    conn = db._conn()
+    try:
+        with conn.cursor() as c:
+            c.execute(
+                f"UPDATE {TABLE} SET overall_score=%s, knowledge_type=%s, llm_evaluation=%s, "
+                f"updated_at=CURRENT_TIMESTAMP WHERE query_id=%s AND case_id=%s",
+                (overall, db._j(knowledge_type or []), db._j(evaluation), QUERY_ID, case_id))
+    finally:
+        conn.close()
+
+
+async def main():
+    rows = _load_db_rows()
+    def _ev(r):
+        e = r["llm_evaluation"]
+        return json.loads(e) if isinstance(e, str) else (e or {})
+    adopted = [r for r in rows if db.is_adopted(r["overall_score"], _ev(r), r["publish_time"])]
+    adopted_ids = {r["case_id"] for r in adopted}
+    print(f"q0000 共 {len(rows)} 帖,当前命中 {len(adopted)} 帖 → 重评这些\n")
+
+    # 备份旧得分字段
+    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+    backup = [{"case_id": r["case_id"], "overall_score": r["overall_score"],
+               "knowledge_type": r["knowledge_type"], "publish_time": r["publish_time"],
+               "llm_evaluation": _ev(r)} for r in adopted]
+    bpath = MW / "runs" / TABLE / f"{QUERY_ID}.score_backup.{ts}.json"
+    bpath.write_text(json.dumps(backup, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"💾 旧得分已备份 → {bpath.name}\n")
+
+    # 从 runs json 取完整帖子(含配图)作为重评输入
+    data = json.loads((MW / "runs" / TABLE / f"{QUERY_ID}.json").read_text(encoding="utf-8"))
+    query = data.get("query", "")
+    by_id = {s["case_id"]: s for s in data.get("results", [])}
+    missing = [cid for cid in adopted_ids if cid not in by_id]
+    if missing:
+        print(f"⚠️ runs json 缺 {len(missing)} 条,将跳过: {missing}")
+    targets = []
+    for cid in adopted_ids:
+        if cid not in by_id:
+            continue
+        s = copy.deepcopy(by_id[cid])
+        for k in _EVAL_PRODUCT_FIELDS:
+            s.pop(k, None)
+        s.pop("_image_data_urls", None)
+        targets.append(s)
+
+    eval_llm, eval_model = build_eval_llm_call(INIT_MODEL)
+    esc_llm, esc_model = build_eval_llm_call(ESC_MODEL)
+    print(f"🧠 组合评估:{eval_model} 初评 → {esc_model} 复核(带 [{BAND[0]:g},{BAND[1]:g}])\n")
+    sources, cost = await evaluate_posts(
+        targets, "", eval_llm, eval_model, max_concurrent=4,
+        include_images=True, max_images=4, image_mode="url", query=query,
+        escalate_llm=esc_llm, escalate_model=esc_model, escalate_band=BAND)
+
+    # 旧分查表
+    old_by_id = {r["case_id"]: r for r in adopted}
+    report = []
+    for s in sources:
+        cid = s["case_id"]
+        ev = s["llm_evaluation"]
+        if not isinstance(ev, dict) or ev.get("_error"):
+            print(f"   ⚠️ 评估失败,跳过更新: {cid}")
+            continue
+        kt = ev.get("知识类型") or []
+        ov = db.overall_score(ev)
+        pub = (s.get("post") or {}).get("publish_timestamp") or old_by_id[cid]["publish_time"]
+        new_adopt = db.is_adopted(ov, ev, pub)
+        _update_scores(cid, ov, kt, ev)            # 定向替换 DB
+        by_id[cid]["llm_evaluation"] = ev          # 同步 runs json
+        report.append({
+            "case_id": cid, "escalated": bool(s.get("_escalated")),
+            "old_overall": old_by_id[cid]["overall_score"], "new_overall": ov,
+            "repro": db._fixed_dim_score(ev, "可复现性"),
+            "intent": db._fixed_dim_score(ev, "意图可控性"),
+            "new_adopted": new_adopt,
+            "title": (s.get("post") or {}).get("title", "")[:22],
+        })
+
+    # 同步 runs json
+    (MW / "runs" / TABLE / f"{QUERY_ID}.json").write_text(
+        json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    # 报告
+    print("\n" + "=" * 92)
+    print(f"{'case_id':26} {'升级':4} {'旧综':>5} {'新综':>5} {'复现':>4} {'意图':>4} {'命中':>5}  标题")
+    still = 0
+    for r in sorted(report, key=lambda x: x["new_overall"]):
+        still += int(r["new_adopted"])
+        print(f"{r['case_id'][:26]:26} {'★' if r['escalated'] else ' ':^4} "
+              f"{(r['old_overall'] or 0):5.2f} {(r['new_overall'] or 0):5.2f} "
+              f"{str(r['repro']):>4} {str(r['intent']):>4} "
+              f"{'是' if r['new_adopted'] else '否':>4}  {r['title']}")
+    esc_n = sum(r["escalated"] for r in report)
+    print("=" * 92)
+    print(f"重评 {len(report)} 帖 · 升级 sonnet {esc_n} 帖 · 命中 {len(adopted)}→{still} · "
+          f"总成本 ${cost:.4f}")
+    print(f"DB 已更新,旧值备份在 {bpath.name}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 35 - 1
examples/mode_workflow/_reeval_one.py

@@ -2,6 +2,7 @@
 """一次性:用当前 eval_prompt_template.md 对单条已存帖子重评(复用生产评估链路 evaluate_posts)。
 支持 --escalate-model 演示 sonnet+flash-lite 组合(模糊带升级)。"""
 import argparse, asyncio, json, sys
+from datetime import datetime
 from pathlib import Path
 
 PROJECT_ROOT = Path(__file__).resolve().parents[3]   # …/Agent
@@ -24,6 +25,11 @@ def _load(query_id):
                       .read_text(encoding="utf-8"))
 
 
+def _save(query_id, data):
+    (MW / "runs" / "search_process" / f"{query_id}.json").write_text(
+        json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
 async def main():
     ap = argparse.ArgumentParser()
     ap.add_argument("--query-id", required=True)
@@ -33,6 +39,8 @@ async def main():
     ap.add_argument("--escalate-model", default="")
     ap.add_argument("--escalate-band", type=float, nargs=2, default=[4.0, 6.0])
     ap.add_argument("--max-images", type=int, default=4)
+    ap.add_argument("--persist", action="store_true",
+                    help="把新评估写回 DB(overall_score/knowledge_type/llm_evaluation),落库前先备份旧值")
     a = ap.parse_args()
 
     data = _load(a.query_id)
@@ -65,11 +73,37 @@ async def main():
     print(f"最终评估模型 = {sources[0].get('_escalated') or model_id}")
     print(f"综合分(overall_score) = {overall}")
     print(f"  · 和内容制作知识相关 = {((ev.get('相关性') or {}).get('和内容制作知识相关') or {}).get('得分')}")
-    print(f"  · 可复现性          = {db._fixed_dim_score(ev, '可复现性')}   (门槛 <4 → 不采纳)")
+    print(f"  · 实现完整性/可复现门槛 = {db._repro_score(ev)}   (门槛 <4 → 不采纳)")
     print(f"  · 意图可控性        = {db._fixed_dim_score(ev, '意图可控性')}  (暂只采分)")
     print(f"采纳判定(is_adopted)  = {adopted}")
     print(f"总成本 ≈ ${cost:.4f}")
 
+    if a.persist:
+        if not isinstance(ev, dict) or ev.get("_error"):
+            raise SystemExit("评估结果异常(_error),拒绝落库")
+        # 1) 备份旧 DB 行(overall_score/knowledge_type/llm_evaluation/publish_time)
+        old = next((p for p in db.fetch_posts(a.query_id, "process")
+                    if p["case_id"] == a.case_id), None)
+        if old is None:
+            raise SystemExit(f"DB 无此行,无法落库: query={a.query_id} case={a.case_id}")
+        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+        bpath = (MW / "runs" / "search_process"
+                 / f"{a.query_id}.{a.case_id}.score_backup.{ts}.json")
+        bpath.write_text(json.dumps({
+            "query_id": a.query_id, "case_id": a.case_id,
+            "old_overall_score": old.get("overall_score"),
+            "old_knowledge_type": old.get("knowledge_type"),
+            "old_llm_evaluation": old.get("llm_evaluation"),
+            "old_adopted": old.get("adopted"),
+        }, ensure_ascii=False, indent=2), encoding="utf-8")
+        # 2) 写回 DB(派生列 overall_score/knowledge_type 由 update_post_eval 重算)
+        n = db.update_post_eval(a.query_id, a.case_id, ev, table="search_process")
+        # 3) 同步 runs json,保持后续重评输入一致
+        src["llm_evaluation"] = ev
+        _save(a.query_id, data)
+        print(f"\n💾 旧值已备份 → {bpath.name}")
+        print(f"✅ DB 已更新 {n} 行(overall={overall} 采纳={adopted})")
+
 
 if __name__ == "__main__":
     asyncio.run(main())

+ 59 - 7
examples/mode_workflow/db.py

@@ -300,10 +300,38 @@ def _fixed_dim_score(evaluation, name):
         return None
 
 
+def _impl_score(evaluation):
+    """取 质量.动态维度.工序.字段完整性.实现完整性.得分 标量,缺失/非数值返回 None。
+    新版 prompt 把旧「可复现性」的硬封顶规则并入了「实现完整性」,故采纳门槛改读此处。"""
+    v = ((((((evaluation or {}).get("质量") or {}).get("动态维度") or {})
+           .get("工序") or {}).get("字段完整性") or {}).get("实现完整性"))
+    if isinstance(v, dict):
+        v = v.get("得分")
+    try:
+        return float(v) if v is not None else None
+    except (TypeError, ValueError):
+        return None
+
+
+def _repro_score(evaluation):
+    """采纳门槛用的「可复现/可实现」得分:优先旧版「可复现性」(固定维度),
+    缺失则回退新版「实现完整性」(动态维度.工序)。这样新旧两套评估 blob 都能正确判定。"""
+    v = _fixed_dim_score(evaluation, "可复现性")
+    return v if v is not None else _impl_score(evaluation)
+
+
 def is_adopted(overall, evaluation, publish_time):
     """采纳/命中判定,口径对齐 mode_procedure 的 decision=="report":
-    制作相关性<4、可复现性<4、发布超两年、综合分<6 —— 任一命中即不采纳;指标缺失不参与判定。
-    (意图可控性暂只采分不设门槛,留待阈值标定后再开。)"""
+    制作相关性<4、可复现/实现完整性<4、发布超两年、综合分<6 —— 任一命中即不采纳;指标缺失不参与判定。
+    (意图可控性暂只采分不设门槛,留待阈值标定后再开。)
+    可复现/实现门槛兼容新旧 schema:旧版读「可复现性」,新版读「实现完整性」(见 _repro_score)。
+
+    fail-closed:评估失败(_error)、blob 缺失/为空、或综合分算不出(None)→ 直接判不采纳。
+    评不出的帖子不该混进命中集(此前 fail-open 会因各指标取不到值而误判采纳)。"""
+    if not isinstance(evaluation, dict) or not evaluation or evaluation.get("_error"):
+        return False
+    if overall is None:
+        return False
     rel = None
     v = ((evaluation or {}).get("相关性") or {}).get("和内容制作知识相关")
     if isinstance(v, dict):
@@ -314,7 +342,7 @@ def is_adopted(overall, evaluation, publish_time):
         rel = None
     if rel is not None and rel < 4:
         return False
-    repro = _fixed_dim_score(evaluation, "可复现性")
+    repro = _repro_score(evaluation)
     if repro is not None and repro < 4:
         return False
     rh = _recency_hard(publish_time)
@@ -326,8 +354,11 @@ def is_adopted(overall, evaluation, publish_time):
 
 
 def is_adopted_rel(overall, rel, publish_time, repro=None):
-    """is_adopted 的轻量版:相关性得分(rel)、可复现性(repro)已由 SQL JSON_EXTRACT 直接取出,
-    无需传输/解析整块 llm_evaluation。判定口径与 is_adopted 完全一致。"""
+    """is_adopted 的轻量版:相关性得分(rel)、可复现/实现门槛(repro)已由 SQL JSON_EXTRACT
+    直接取出(repro 由 _REPRO_SQL 兼容新旧 schema 取值),无需传输/解析整块 llm_evaluation。
+    判定口径与 is_adopted 完全一致(含 fail-closed:综合分算不出→不采纳;失败帖的 overall_score 列为 NULL)。"""
+    if overall is None:
+        return False
     try:
         rel = float(rel) if rel is not None else None
     except (TypeError, ValueError):
@@ -738,10 +769,13 @@ _REL_SQL = ("JSON_UNQUOTE(COALESCE("
             "JSON_EXTRACT(llm_evaluation,'$.\"相关性\".\"和内容制作知识相关\".\"得分\"'),"
             "JSON_EXTRACT(llm_evaluation,'$.\"相关性\".\"和内容制作知识相关\"')))")
 
-# 可复现性门槛同样需要标量直取(口径同 is_adopted 的 _fixed_dim_score)。
+# 可复现/实现门槛标量直取(口径同 is_adopted 的 _repro_score):兼容新旧 schema——
+# 旧版「质量.固定维度.可复现性」,新版「质量.动态维度.工序.字段完整性.实现完整性」,COALESCE 依次回退。
 _REPRO_SQL = ("JSON_UNQUOTE(COALESCE("
               "JSON_EXTRACT(llm_evaluation,'$.\"质量\".\"固定维度\".\"可复现性\".\"得分\"'),"
-              "JSON_EXTRACT(llm_evaluation,'$.\"质量\".\"固定维度\".\"可复现性\"')))")
+              "JSON_EXTRACT(llm_evaluation,'$.\"质量\".\"固定维度\".\"可复现性\"'),"
+              "JSON_EXTRACT(llm_evaluation,'$.\"质量\".\"动态维度\".\"工序\".\"字段完整性\".\"实现完整性\".\"得分\"'),"
+              "JSON_EXTRACT(llm_evaluation,'$.\"质量\".\"动态维度\".\"工序\".\"字段完整性\".\"实现完整性\"')))")
 
 
 def fetch_adopted_process_cases(query_id=None):
@@ -796,6 +830,24 @@ def fetch_existing_eval(case_id, table="search_process"):
     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)。返回受影响行数。"""
+    table = _search_table(table)
+    overall = overall_score(evaluation)
+    ktype = evaluation.get("知识类型") if isinstance(evaluation, dict) else None
+    conn = _conn()
+    try:
+        with conn.cursor() as cur:
+            n = cur.execute(
+                f"UPDATE {table} SET llm_evaluation=%s, overall_score=%s, knowledge_type=%s "
+                "WHERE query_id=%s AND case_id=%s",
+                (_j(evaluation), overall, _j(ktype), query_id, case_id))
+        return n
+    finally:
+        conn.close()
+
+
 # ── 上传去重:知识库已导入台账(import_process_knowledge.py 用)────────────────
 
 def fetch_ingested_map(case_id):

+ 113 - 0
examples/mode_workflow/eval_compare.py

@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+"""一次性:用当前 eval_prompt_template.md(新 prompt)对单帖重评,与库里旧评估对比打分。
+用法: python eval_compare.py <query_id> <case_id>
+"""
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]   # …/Agent
+sys.path.insert(0, str(PROJECT_ROOT))
+from dotenv import load_dotenv
+load_dotenv()
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+import db
+
+from examples.process_pipeline.script.search_eval.search_and_evaluate import _attach_image_refs
+from examples.process_pipeline.script.llm_evaluate_sources import (
+    _evaluate_one, build_eval_llm_call, DEFAULT_EVAL_MODEL,
+)
+
+
+def _row_to_source(row):
+    return {
+        "case_id": row["case_id"], "platform": row["platform"],
+        "channel_content_id": row["channel_content_id"], "source_url": row["url"],
+        "post": {
+            "title": row["title"], "body_text": row["body"],
+            "images": row["images"] or [], "like_count": row["like_count"],
+            "publish_timestamp": row["publish_time"], "link": row["url"],
+        },
+    }
+
+
+def flatten_scores(blob, prefix=""):
+    """blob → {dotted_path: 得分}。只收叶子 {得分:...} 节点。"""
+    out = {}
+    if not isinstance(blob, dict):
+        return out
+    if "得分" in blob:
+        out[prefix.rstrip(".")] = blob.get("得分")
+        return out
+    for k, v in blob.items():
+        if isinstance(v, dict):
+            out.update(flatten_scores(v, f"{prefix}{k}."))
+    return out
+
+
+async def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("query_id")
+    ap.add_argument("case_id")
+    ap.add_argument("--model", default=DEFAULT_EVAL_MODEL)
+    ap.add_argument("--max-images", type=int, default=4)
+    args = ap.parse_args()
+
+    row = db.fetch_post(args.query_id, args.case_id, table="search_process")
+    if not row:
+        print(f"❌ {args.query_id}/{args.case_id} 不在 search_process"); return 1
+    old_blob = row.get("llm_evaluation") or {}
+
+    src = _row_to_source(row)
+    await _attach_image_refs([src], args.max_images, 8, "url")
+    n_img = len(src.get("_image_data_urls") or [])
+    print(f"📄 {args.case_id} | {(row['title'] or '')[:40]} | 配图 {n_img} 张 | 模型 {args.model}")
+    print(f"🔍 检索词: {row['query_text']}\n")
+
+    eval_llm, model_id = build_eval_llm_call(args.model)
+    sem = asyncio.Semaphore(1)
+    new_blob, cost = await _evaluate_one(
+        src, "", eval_llm, model_id, sem,
+        image_urls=src.get("_image_data_urls"), query=row["query_text"])
+    if new_blob is None:
+        print("❌ 新评估失败(重试耗尽)"); return 1
+
+    old_f = flatten_scores(old_blob)
+    new_f = flatten_scores(new_blob)
+    keys = sorted(set(old_f) | set(new_f))
+    print(f"{'维度路径':<46} {'旧分':>6} {'新分':>6}   变化")
+    print("─" * 72)
+    for k in keys:
+        o, n = old_f.get(k), new_f.get(k)
+        mark = ""
+        try:
+            if o is not None and n is not None and float(o) != float(n):
+                mark = f"  {float(o):g}→{float(n):g}"
+        except (TypeError, ValueError):
+            pass
+        only = "" if (k in old_f and k in new_f) else ("  (旧无)" if k not in old_f else "  (新无)")
+        print(f"{k:<46} {str(o) if o is not None else '-':>6} {str(n) if n is not None else '-':>6}{mark}{only}")
+
+    print("─" * 72)
+    o_overall, n_overall = db.overall_score(old_blob), db.overall_score(new_blob)
+    o_adopt = db.is_adopted(o_overall, old_blob, row["publish_time"])
+    n_adopt = db.is_adopted(n_overall, new_blob, row["publish_time"])
+    print(f"{'overall_score':<46} {str(o_overall):>6} {str(n_overall):>6}")
+    print(f"{'知识类型':<46} {str(old_blob.get('知识类型')):>6} | {new_blob.get('知识类型')}")
+    print(f"{'是否采纳':<46} {str(o_adopt):>6} {str(n_adopt):>6}")
+    print(f"\n💲 本次重评成本 ${cost:.4f}")
+
+    # 落盘完整新 blob,便于细看理由
+    out = HERE / "runs" / f"eval_compare_{args.case_id}.json"
+    out.write_text(json.dumps({"old": old_blob, "new": new_blob}, ensure_ascii=False, indent=2),
+                   encoding="utf-8")
+    print(f"📝 完整新旧 blob(含理由): {out}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(asyncio.run(main()))

+ 218 - 12
examples/mode_workflow/index.html

@@ -232,6 +232,21 @@
         border-color: #a7d9b4;
         color: var(--green);
       }
+      .pill.blue {
+        background: var(--blue-bg);
+        border-color: #b6cdf7;
+        color: var(--blue);
+      }
+      /* 目的列 intent 胶囊: 底色 = 所引用列的分组色, 与表头/列 chip 一致
+         (需求=navy / 输入=amber / 实现=teal / 输出=green; 口径同 procedure-dsl「token 色对应来源列」) */
+      .intent-text { color: var(--ink); line-height: 1.6; }
+      .intent-tok { display: inline-block; padding: 1px 6px; border-radius: 4px; margin: 0 1px; font-size: 11.5px; font-weight: 500; }
+      .intent-tok.ik-effect   { background: #e8eef6; color: var(--navy); }                          /* 作用列 (需求组) */
+      .intent-tok.ik-via      { background: var(--teal-bg); color: var(--teal); font-family: "IBM Plex Mono", ui-monospace, monospace; }  /* 外部工具列 (实现组) */
+      .intent-tok.ik-act      { background: var(--teal-bg); color: var(--teal); }                   /* 动作列 (实现组) */
+      .intent-tok.ik-in-type  { background: var(--amber-bg); color: var(--amber); border: 1px solid #ecc88a; border-radius: 99px; padding: 1px 8px; }  /* 输入·类型 */
+      .intent-tok.ik-out-type { background: var(--blue-bg); color: var(--blue); border: 1px solid #b6cdf7; border-radius: 99px; padding: 1px 8px; }  /* 输出·类型 (蓝色,避免与实现组绿色混淆) */
+      .intent-tok.ik-other    { background: #fbeae5; color: var(--seal); text-decoration: line-through; }  /* 非法类别(lint 警告) */
       .btn {
         border: 1px solid var(--line-dark);
         background: var(--card);
@@ -917,10 +932,10 @@
         background: #2d8273;
       }
       .steps .h-out {
-        background: var(--green);
+        background: var(--blue);
       }
       .steps .h-out2 {
-        background: #2e6b45;
+        background: #4f7fe6;
       }
       .steps td {
         padding: 8px 9px;
@@ -935,7 +950,7 @@
         background: var(--amber-bg) !important;
       }
       .steps td.c-out {
-        background: var(--green-bg) !important;
+        background: var(--blue-bg) !important;
       }
       .steps .sid {
         font-family: "IBM Plex Mono", monospace;
@@ -993,7 +1008,7 @@
         background: linear-gradient(180deg, rgba(255, 247, 232, 0), rgba(255, 247, 232, 1));
       }
       .steps td.c-out .clamp-val.clampable::after {
-        background: linear-gradient(180deg, rgba(239, 250, 241, 0), rgba(239, 250, 241, 1));
+        background: linear-gradient(180deg, rgba(238, 243, 254, 0), rgba(238, 243, 254, 1));
       }
       .clamp-val.open {
         max-height: none;
@@ -1166,6 +1181,84 @@
         background: rgba(19, 30, 46, 0.5);
         backdrop-filter: blur(2px);
       }
+      /* 重新解构·编辑 Prompt 弹框 */
+      dialog#reextract-dlg {
+        border: none;
+        border-radius: 14px;
+        padding: 0;
+        width: min(680px, 94vw);
+        box-shadow: var(--shadow-lg);
+        margin: auto;
+      }
+      dialog#reextract-dlg::backdrop {
+        background: rgba(19, 30, 46, 0.42);
+        backdrop-filter: blur(2px);
+      }
+      .rx-wrap {
+        display: flex;
+        flex-direction: column;
+        max-height: calc(100vh - 48px);
+      }
+      .rx-head {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 14px 18px 12px;
+        border-bottom: 1px solid var(--line);
+      }
+      .rx-title {
+        font-weight: 700;
+        font-size: 15px;
+        color: var(--navy-deep);
+      }
+      .rx-sub {
+        padding: 12px 18px 0;
+        font-size: 12px;
+        color: var(--ink-faint);
+        line-height: 1.6;
+      }
+      .rx-model {
+        padding: 12px 18px 0;
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        font-size: 13px;
+      }
+      .rx-model label {
+        font-weight: 600;
+        color: var(--ink);
+      }
+      .rx-prompt {
+        margin: 12px 18px;
+        flex: 1;
+        min-height: 300px;
+        resize: vertical;
+        font-family: "IBM Plex Mono", ui-monospace, monospace;
+        font-size: 12px;
+        line-height: 1.6;
+        color: var(--ink);
+        border: 1px solid var(--line-dark);
+        border-radius: 8px;
+        padding: 12px;
+        background: #fbfaf6;
+      }
+      .rx-foot {
+        display: flex;
+        justify-content: flex-end;
+        gap: 10px;
+        padding: 12px 18px 16px;
+        border-top: 1px solid var(--line);
+      }
+      #rx-save {
+        background: var(--green);
+        border-color: var(--green);
+        color: #fff;
+      }
+      #rx-save:hover {
+        background: #126b33;
+        color: #fff;
+        box-shadow: 0 4px 12px rgba(21, 128, 61, 0.3);
+      }
       .pd-wrap {
         display: flex;
         flex-direction: column;
@@ -1423,6 +1516,17 @@
         color: var(--blue);
         text-decoration: none;
       }
+      .src-case {
+        flex: 0 0 auto;
+        font-size: 11px;
+        font-family: "IBM Plex Mono", ui-monospace, monospace;
+        color: var(--ink-faint);
+        background: var(--paper, #f3f2ed);
+        border: 1px solid var(--line);
+        padding: 1px 7px;
+        border-radius: 4px;
+        white-space: nowrap;
+      }
       .src-body {
         padding: 0 12px 12px;
       }
@@ -2135,6 +2239,49 @@
       </div>
     </dialog>
 
+    <!-- 重新解构 · 编辑解构 Prompt -->
+    <dialog id="reextract-dlg">
+      <div class="rx-wrap">
+        <div class="rx-head">
+          <div class="rx-title">重新解构 · 编辑解构 Prompt</div>
+          <button
+            class="btn sm"
+            onclick="document.getElementById('reextract-dlg').close()"
+          >
+            关闭
+          </button>
+        </div>
+        <div class="rx-sub">
+          修改下面的解构 Prompt 后,点「保存修改」将按最新 Prompt
+          重新解构本帖(生成新版本,旧版本保留)。改动仅本次生效,不会改写默认 Prompt。
+        </div>
+        <div class="rx-model">
+          <label>模型:</label>
+          <select id="rx-model"></select>
+        </div>
+        <textarea
+          id="rx-prompt"
+          class="rx-prompt"
+          spellcheck="false"
+        ></textarea>
+        <div class="rx-foot">
+          <button
+            class="btn"
+            onclick="document.getElementById('reextract-dlg').close()"
+          >
+            取消
+          </button>
+          <button
+            class="btn primary"
+            id="rx-save"
+            onclick="saveReextract()"
+          >
+            保存修改
+          </button>
+        </div>
+      </div>
+    </dialog>
+
     <!-- 图片预览灯箱(支持左右切换) -->
     <dialog
       class="lightbox"
@@ -2274,6 +2421,25 @@
           /[&<>"']/g,
           (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
         );
+      /* 目的列: 把 intent 里的 {类别:值} 标记渲染成彩色胶囊 (口径同 procedure-dsl renderer.py:render_intent)。
+         合法类别 5 个: effect/via/act/in-type/out-type; 其余落 ik-other(红删除线,当 lint 警告)。
+         标记外的字面文字与值都做 HTML 转义。 */
+      const INTENT_KIND = {
+        effect: "ik-effect", via: "ik-via", act: "ik-act",
+        "in-type": "ik-in-type", "out-type": "ik-out-type",
+      };
+      const renderIntent = (text) => {
+        const s = String(text ?? "");
+        const re = /\{([\w-]+):([^}]+)\}/g;
+        let out = "", last = 0, m;
+        while ((m = re.exec(s))) {
+          out += esc(s.slice(last, m.index).replace(/`/g, ""));
+          const kc = INTENT_KIND[m[1]] || "ik-other";
+          out += `<span class="intent-tok ${kc}">${esc(m[2])}</span>`;
+          last = m.index + m[0].length;
+        }
+        return out + esc(s.slice(last).replace(/`/g, ""));
+      };
       /* 外链图片走本服务同源反代,绕过公众号(mmbiz.qpic.cn)等防盗链 */
       const imgProxy = (u) => (/^https?:\/\//.test(u || "") ? "/api/img?u=" + encodeURIComponent(u) : u || "");
       /* 图片加载策略:优先「浏览器直连 CDN」(referrerpolicy=no-referrer 多数能绕防盗链),
@@ -2869,6 +3035,7 @@
         if (p.like_count != null) meta.push(`<span>👍 ${p.like_count}</span>`);
         if (p.quality_grade) meta.push(`<span>质量 ${esc(p.quality_grade)} ${p.quality_score ?? ""}</span>`);
         if (p.url) meta.push(`<a href="${esc(p.url)}" target="_blank">原文 ↗</a>`);
+        meta.push(`<span class="pill" title="case_id" style="font-family:'IBM Plex Mono',ui-monospace,monospace">${esc(p.case_id || cid)}</span>`);
         $("#pd-meta").innerHTML = meta.join("");
         $("#pd-title").textContent = p.title || "(无标题)";
         const verdict = e["判定理由"] || e["理由"] || "";
@@ -3008,15 +3175,13 @@
               `<option value="${esc(v.version)}">${esc(v.version)}${i === 0 ? " (最新)" : ""} · ${v.n}${isProc ? "工序" : "工具"}</option>`,
           )
           .join("");
-        const models = (isProc ? MODELS_PROC : MODELS_TOOL).map((m) => `<option>${m}</option>`).join("");
         const stat = loading ? "加载中…" : missing ? "未提取" : "已提取";
         $("#xp-head").innerHTML = `
     <span class="st">大模型${isProc ? "工序" : "工具"}:<em>${stat}</em></span>
     <span style="font-size:12px;color:var(--ink-faint);max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title}</span>
     <span class="spacer"></span>
     ${versions.length ? `<select id="ver-sel">${opts}</select>` : ""}
-    <select id="model-sel" title="解构模型">${models}</select>
-    <button class="btn sm primary" onclick="startExtract(['${esc(state.caseId || "")}'])">${missing ? "提取" : "♻ 重新生成"}</button>
+    <button class="btn sm primary" onclick="openReextractDialog()">${missing ? "提取" : "♻ 重新生成"}</button>
     <button class="btn sm" onclick="showTaskPanel()" title="重新打开任务日志面板">📋 操作日志</button>`;
         const vs = $("#ver-sel");
         if (vs)
@@ -3058,6 +3223,7 @@
         <span class="src-label">原文</span>
         <span class="src-title">${esc(p.title || "(无标题)")}</span>
         ${link}
+        <span class="src-case" title="case_id" onclick="event.stopPropagation()">${esc(p.case_id || "")}</span>
       </div>
       <div class="src-body">${thumbs}${body}</div>
     </div>`;
@@ -3142,7 +3308,7 @@
             rows += "<tr>";
             if (i === 0) {
               rows += `<td rowspan="${n}" class="sid">${esc(s.id || "")}</td>
-          <td rowspan="${n}">${esc(s.directive || s.intent || "")}</td>
+          <td rowspan="${n}"><div class="intent-text">${renderIntent(s.intent || s.directive || "")}</div></td>
           <td rowspan="${n}">${s.effect ? `<span class="pill navy">${esc(s.effect)}</span>` : ""}</td>
           <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
           <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;
@@ -3182,7 +3348,7 @@
         if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
         const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || "模型推断补全")}` : "";
         const badge = x.inferred ? '<span class="ib">推</span>' : "";
-        return `<td class="${cls}"><span class="pill ${kind === "in" ? "amber" : "teal"}">${esc(x.type || "")}</span></td>
+        return `<td class="${cls}"><span class="pill ${kind === "in" ? "amber" : "blue"}">${esc(x.type || "")}</span></td>
     <td class="${cls}${inf}">${badge}<div class="clamp-val" onclick="toggleClampVal(this)"><span class="vtxt">${esc(x.value || "")}</span></div></td>
     <td class="${cls}"><span class="anchor">${esc(x.anchor || "")}</span></td>`;
       }
@@ -3296,15 +3462,55 @@
         });
       }
 
+      /* ════ 重新解构 · 编辑 Prompt 弹框 ════ */
+      async function openReextractDialog() {
+        if (!state.caseId) {
+          toast("请先选择帖子", "info");
+          return;
+        }
+        const isProc = state.mode === "process";
+        const dlg = $("#reextract-dlg"),
+          sel = $("#rx-model"),
+          ta = $("#rx-prompt"),
+          save = $("#rx-save");
+        sel.innerHTML = (isProc ? MODELS_PROC : MODELS_TOOL)
+          .map((m) => `<option>${m}</option>`)
+          .join("");
+        ta.value = "加载中…";
+        ta.disabled = save.disabled = true;
+        dlg.showModal();
+        try {
+          const r = await api(`/api/extract_prompt?mode=${state.mode}`);
+          ta.value = r.prompt || "";
+        } catch (e) {
+          ta.value = "";
+          toast("加载 Prompt 失败:" + (e.body?.error || e.status), "error");
+        } finally {
+          ta.disabled = save.disabled = false;
+        }
+      }
+      async function saveReextract() {
+        const cid = state.caseId;
+        if (!cid) return;
+        const model = $("#rx-model").value,
+          prompt = $("#rx-prompt").value;
+        $("#reextract-dlg").close();
+        // 临时 prompt 覆盖 + force 重解构(仅本次生效,不改默认 Prompt)
+        await startExtract([cid], { model, prompt, force: true });
+      }
+
       /* ════ 解构任务 ════ */
-      async function startExtract(caseIds) {
+      async function startExtract(caseIds, opts = {}) {
         if (!state.queryId || !caseIds.length) return;
         const isProc = state.mode === "process";
-        const model = $("#model-sel")?.value || (isProc ? MODELS_PROC[0] : MODELS_TOOL[0]);
+        const model = opts.model || (isProc ? MODELS_PROC[0] : MODELS_TOOL[0]);
         try {
+          const body = { query_id: state.queryId, case_ids: caseIds, model };
+          if (opts.prompt != null) body.prompt = opts.prompt; // 临时 prompt 覆盖(仅本次)
+          if (opts.force) body.force = true; // 换 prompt/模型重解构需跳过去重
           const r = await api(`/api/extract_${isProc ? "process" : "tools"}`, {
             method: "POST",
-            body: JSON.stringify({ query_id: state.queryId, case_ids: caseIds, model }),
+            body: JSON.stringify(body),
           });
           // 全部正在解构中(被认领跳过):没有 task_id,提示一下即可,别去轮询空任务
           if (!r.task_id) {

+ 4 - 1
examples/mode_workflow/pipeline/procedure_extract.py

@@ -222,7 +222,8 @@ async def run(args):
             return 0
         print("❌ 没有可解构的帖子"); return 1
 
-    system = PROMPT_FILE.read_text(encoding="utf-8")
+    prompt_file = Path(args.prompt_file) if getattr(args, "prompt_file", None) else PROMPT_FILE
+    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)
     args.version = args.version or ("v_" + datetime.now().strftime("%m%d%H%M"))
@@ -247,6 +248,8 @@ def main():
     p.add_argument("--no-images", action="store_true")
     p.add_argument("--force", action="store_true",
                    help="跳过去重,强制重解构(换 prompt/模型做对比时用)")
+    p.add_argument("--prompt-file", default=None,
+                   help="覆盖默认解构 prompt(临时,仅本次;不改 prompts/*.md)")
     args = p.parse_args()
     raise SystemExit(asyncio.run(run(args)))
 

+ 5 - 0
examples/mode_workflow/pipeline/tool_extract.py

@@ -162,7 +162,12 @@ def main():
     p.add_argument("--version", default=None, help="默认自动 v_月日时分")
     p.add_argument("--force", action="store_true",
                    help="跳过去重,强制重解构(换 prompt/模型做对比时用)")
+    p.add_argument("--prompt-file", default=None,
+                   help="覆盖默认解构 prompt(临时,仅本次;不改 prompts/*.md)")
     args = p.parse_args()
+    if args.prompt_file:
+        global TOOL_SYSTEM
+        TOOL_SYSTEM = Path(args.prompt_file).read_text(encoding="utf-8")
     raise SystemExit(asyncio.run(run(args)))
 
 

+ 32 - 14
examples/mode_workflow/prompts/procedure_extract_system.md

@@ -86,20 +86,19 @@
 
 ### 字段规范
 
-| 字段                 | 规范                                                                                                                          |
-| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
-| step `id`            | `s1`、`s2`;控制块子步用点号 `s5.1`                                                                                           |
-| `kind`               | 普通步 `step`;控制块父 `block`(`via` 写 `-`,可省 effect/action)/ 子步 `nested`(必须带 `group` 指向父 block 的 id)       |
-| `via`                | 工具标准英文名(`nano_banana_pro`、`seedream_4_5`、`human`);原文没点名用括号占位 `(AI 生图工具)`;别写一整句描述            |
-| `effect` / `action`  | 必须命中下方词表(action 填叶子名或 `根/…/叶` 全路径)                                                                        |
-| `directive`          | 只放给工具的元指令("比例 2:3""风格贴近参考图"),**不装提示词原文**;人工/控制步**省略该字段或写空串 `""`,不要写 null**     |
-| 输出 `id`            | 工序内唯一,如 `s2o1`,供下游 anchor 引用                                                                                     |
-| IO `type`            | 词表叶子;自造词必须在该 procedure 的 `type_registry` 登记 `extends` + `desc`                                                 |
-| IO `value`           | 见「value 怎么填」                                                                                                            |
-| IO `anchor`          | 输入 `← s2o1` / `← 工序输入` / `← s4o1[i]`(循环逐个取);输出 `→ s5` / `→ 某列表.追加` / `→ 返回 X`                          |
-| `inferred`           | 原文没写、工艺上必有的 IO 主动补:`"inferred": true` + `"inferred_reason": "为什么必有"`                                      |
-| `intent`             | 每步 ≤25 字一句通顺人话;关键词用 `{effect:}` `{via:}` `{act:}` `{in-type:}` `{out-type:}` 五类标记;别写成公式(不出现 `→`) |
-| `substance` / `form` | 见「实质 / 形式」;没有就显式 `null`,不能省略字段                                                                            |
+| 字段                 | 规范                                                                                                                      |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| step `id`            | `s1`、`s2`;控制块子步用点号 `s5.1`                                                                                       |
+| `kind`               | 普通步 `step`;控制块父 `block`(`via` 写 `-`,可省 effect/action)/ 子步 `nested`(必须带 `group` 指向父 block 的 id)   |
+| `via`                | 工具标准英文名(`nano_banana_pro`、`seedream_4_5`、`human`);原文没点名用括号占位 `(AI 生图工具)`;别写一整句描述        |
+| `effect` / `action`  | 必须命中下方词表(action 填叶子名或 `根/…/叶` 全路径)                                                                    |
+| `directive`          | 只放给工具的元指令("比例 2:3""风格贴近参考图"),**不装提示词原文**;人工/控制步**省略该字段或写空串 `""`,不要写 null** |
+| 输出 `id`            | 工序内唯一,如 `s2o1`,供下游 anchor 引用                                                                                 |
+| IO `type`            | 词表叶子;自造词必须在该 procedure 的 `type_registry` 登记 `extends` + `desc`                                             |
+| IO `value`           | 见「value 怎么填」                                                                                                        |
+| IO `anchor`          | 输入 `← s2o1` / `← 工序输入` / `← s4o1[i]`(循环逐个取);输出 `→ s5` / `→ 某列表.追加` / `→ 返回 X`                      |
+| `inferred`           | 原文没写、工艺上必有的 IO 主动补:`"inferred": true` + `"inferred_reason": "为什么必有"`                                  |
+| `substance` / `form` | 见「实质 / 形式」;没有就显式 `null`,不能省略字段                                                                        |
 
 **命名约定**:type 名用中文;工具品牌名用英文标准写法。
 
@@ -214,6 +213,25 @@
 }
 ```
 
+### 目的列(intent)
+
+每步补一个 `intent`:一句话概括这一步在做什么,≤25 字。**跨步骤一起看**(为了让每行各有侧重,避免都写成一个模子),不要逐步孤立地填。
+
+写法规则:
+
+1. **像句人话**(≤25 字),读出来通顺。**别写成公式**(不要出现 `→`、`:` 这种符号串、不要 `A: B → C` 这种结构)。
+2. **关键词用 `{类别:值}` 标出来**,直接用这一行其他列已经有的值。中间可以加"用、把、到、和、得到、为参考"这类连接词。**别用只有你懂的简写**。
+3. **同类的几个值各标各的**:写"得到 `{out-type:提示词}` 和 `{out-type:负向提示词}`",别揉成"得到正负 `{out-type:提示词}`"。
+4. **能用的类别只有 5 个**:`{effect:}`、`{via:}`、`{act:}`、`{in-type:}`、`{out-type:}`。类型必须带 `in-`/`out-` 前缀区分输入输出。
+5. **不要用变量名当标记**(`{in:参考视频}` ❌ → 改 `{in-type:参考视频}`);标的词必须在这一行真实出现过。
+
+例子:
+
+- ✅ 用 `{via:manus}` `{act:反推}` `{in-type:参考视频}`,得到 `{out-type:提示词}` 和 `{out-type:负向提示词}`
+- ✅ 以 `{in-type:参考图}` 和上一张 `{in-type:分镜图}` 为参考,`{act:元素生成}` 当前 `{out-type:分镜图}`
+- ❌ `{act:反推}: {in-type:视频} → {out-type:提示词}`(写成了公式)
+- ❌ 得到正负 `{out-type:提示词}`(揉成一个了)
+
 ### 实质 / 形式(substance / form)
 
 只描述最终产物**画面内容的视觉维度**,与步骤处理什么数据类型无关。

+ 31 - 5
examples/mode_workflow/search.html

@@ -344,11 +344,11 @@
     .steps .h-req { background: #2b4a72; }  .steps .h-req2 { background: #33547a; }
     .steps .h-in  { background: #c2761f; }  .steps .h-in2  { background: #cd7522; }
     .steps .h-im  { background: #2f9c8a; }  .steps .h-im2  { background: #2d8273; }
-    .steps .h-out { background: #3a8757; }  .steps .h-out2 { background: #2e6b45; }
+    .steps .h-out { background: #2563eb; }  .steps .h-out2 { background: #4f7fe6; }
     .steps td { padding: 8px 9px; border: 1px solid var(--border); vertical-align: top; line-height: 1.6; color: var(--text); }
     .steps tbody tr:nth-child(odd) td { background: #fdfdf9; }
     .steps td.c-in  { background: #FFF7ED !important; }
-    .steps td.c-out { background: #F0FDF4 !important; }
+    .steps td.c-out { background: #eef3fe !important; }
     .steps .sid { font-family: 'Courier New', monospace; font-weight: 700; color: #2b4a72; white-space: nowrap; }
     .steps .vtxt { color: var(--text-sub); font-size: 11.5px; word-break: break-all; }
     .steps .anchor { font-family: 'Courier New', monospace; font-size: 10.5px; color: var(--text-muted); word-break: break-all; }
@@ -356,6 +356,18 @@
     .steps .pill.navy  { background: #e7edf5; color: #2b4a72; }
     .steps .pill.amber { background: #fdebd3; color: #9a560f; }
     .steps .pill.teal  { background: #d8f0ea; color: #1f6f62; }
+    .steps .pill.green { background: #e3f3e8; color: #2e6b45; }
+    .steps .pill.blue  { background: #eef3fe; color: #2563eb; }
+    /* 目的列 intent 胶囊: 底色 = 所引用列的分组色, 与表头/列 chip 一致
+       (需求=navy / 输入=amber / 实现=teal / 输出=green; 口径同 procedure-dsl「token 色对应来源列」) */
+    .intent-text { color: #1f2937; line-height: 1.6; }
+    .intent-tok { display: inline-block; padding: 1px 6px; border-radius: 4px; margin: 0 1px; font-size: 11.5px; font-weight: 600; }
+    .intent-tok.ik-effect   { background: #e7edf5; color: #2b4a72; }                       /* 作用列 (需求组) */
+    .intent-tok.ik-via      { background: #d8f0ea; color: #1f6f62; font-family: ui-monospace, "SF Mono", monospace; }  /* 外部工具列 (实现组) */
+    .intent-tok.ik-act      { background: #d8f0ea; color: #1f6f62; }                        /* 动作列 (实现组) */
+    .intent-tok.ik-in-type  { background: #fdebd3; color: #9a560f; border-radius: 99px; padding: 1px 8px; }  /* 输入·类型 */
+    .intent-tok.ik-out-type { background: #eef3fe; color: #2563eb; border: 1px solid #b6cdf7; border-radius: 99px; padding: 1px 8px; }  /* 输出·类型 (蓝色,避免与实现组绿色混淆) */
+    .intent-tok.ik-other    { background: #fbeae5; color: #b3341d; text-decoration: line-through; }  /* 非法类别(lint 警告) */
     .steps .inf { background: #fdf6e3 !important; position: relative; outline: 1px dashed #c9a227; outline-offset: -2px; }
     .steps .inf .ib { position: absolute; top: -1px; right: -1px; background: #c9a227; color: #fff; font-size: 9px; padding: 0 4px; border-radius: 0 0 0 4px; font-weight: 700; }
     .steps-empty { padding: 12px; color: var(--text-muted); font-size: 12px; }
@@ -366,7 +378,7 @@
       content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2.4em; pointer-events: none;
     }
     .steps td.c-in  .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(255,247,237,0), rgba(255,247,237,1)); }
-    .steps td.c-out .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(240,253,244,0), rgba(240,253,244,1)); }
+    .steps td.c-out .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(238,243,254,0), rgba(238,243,254,1)); }
     .clamp-val.open { max-height: none; overflow: visible; cursor: zoom-out; }
     .clamp-val.open::after { display: none; }
   </style>
@@ -426,6 +438,20 @@ const SCOPE_TAGS   = {substance:'s', form:'fo', intent:'i', effect:'e', feeling:
 // ── 转义工具 ──────────────────────────────────────────────────────────
 const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
 
+// 目的列: 把 intent 里的 {类别:值} 标记渲染成彩色胶囊 (口径同 procedure-dsl renderer.py:render_intent)。
+// 合法类别 5 个: effect/via/act/in-type/out-type; 其余落 ik-other(红删除线)。标记外字面与值都转义。
+const INTENT_KIND = { effect:'ik-effect', via:'ik-via', act:'ik-act', 'in-type':'ik-in-type', 'out-type':'ik-out-type' };
+function renderIntent(text) {
+  const s = String(text ?? ''), re = /\{([\w-]+):([^}]+)\}/g;
+  let out = '', last = 0, m;
+  while ((m = re.exec(s))) {
+    out += esc(s.slice(last, m.index).replace(/`/g, ''));
+    out += `<span class="intent-tok ${INTENT_KIND[m[1]] || 'ik-other'}">${esc(m[2])}</span>`;
+    last = m.index + m[0].length;
+  }
+  return out + esc(s.slice(last).replace(/`/g, ''));
+}
+
 function debounce(fn, ms) {
   let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
 }
@@ -862,7 +888,7 @@ function ioCell(x, kind) {
   if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
   const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
   const badge = x.inferred ? '<span class="ib">推</span>' : '';
-  return `<td class="${cls}"><span class="pill ${kind === 'in' ? 'amber' : 'teal'}">${esc(x.type || '')}</span></td>
+  return `<td class="${cls}"><span class="pill ${kind === 'in' ? 'amber' : 'blue'}">${esc(x.type || '')}</span></td>
     <td class="${cls}${inf}">${badge}<div class="clamp-val" onclick="toggleClampVal(this)"><span class="vtxt">${esc(x.value || '')}</span></div></td>
     <td class="${cls}"><span class="anchor">${esc(x.anchor || '')}</span></td>`;
 }
@@ -877,7 +903,7 @@ function renderSteps(steps) {
       rows += '<tr>';
       if (i === 0) {
         rows += `<td rowspan="${n}" class="sid">${esc(s.id || '')}</td>
-          <td rowspan="${n}">${esc(s.directive || s.intent || '')}</td>
+          <td rowspan="${n}"><div class="intent-text">${renderIntent(s.intent || s.directive || '')}</div></td>
           <td rowspan="${n}">${s.effect ? `<span class="pill navy">${esc(s.effect)}</span>` : ''}</td>
           <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
           <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;

+ 15 - 0
examples/mode_workflow/server.py

@@ -424,6 +424,13 @@ class Handler(BaseHTTPRequestHandler):
                 # 一次点击合一:单连接同时取版本列表 + 解构详情,前端少一次往返。
                 self._json_etag(db.fetch_extract(
                     qs.get("mode", "process"), qs.get("case_id", ""), qs.get("version")))
+            elif u.path == "/api/extract_prompt":
+                # 取当前解构 prompt 原文,供「重新解构·编辑 Prompt」弹框预填
+                mode = qs.get("mode", "process")
+                pf = HERE / "prompts" / (
+                    "procedure_extract_system.md" if mode == "process"
+                    else "tool_extract_system.md")
+                self._json({"prompt": pf.read_text(encoding="utf-8") if pf.is_file() else ""})
             elif u.path == "/api/process_versions":
                 self._json(db.fetch_process_versions(qs.get("case_id", "")))
             elif u.path == "/api/process":
@@ -494,6 +501,14 @@ class Handler(BaseHTTPRequestHandler):
                            "--case-ids", ",".join(claimed)]
                     if payload.get("model"):
                         cmd += ["--model", payload["model"]]
+                    # 临时 prompt 覆盖:仅本次解构生效,不改 prompts/*.md(写到 .cache 临时文件传给 pipeline)
+                    prompt_override = payload.get("prompt")
+                    if prompt_override and prompt_override.strip():
+                        pdir = HERE / ".cache" / "prompts"
+                        pdir.mkdir(parents=True, exist_ok=True)
+                        pf = pdir / f"{mode}_{int(time.time() * 1000)}.md"
+                        pf.write_text(prompt_override, encoding="utf-8")
+                        cmd += ["--prompt-file", str(pf)]
                     if payload.get("force"):   # 默认按 case 全局去重;force 才强制重解构
                         cmd += ["--force"]
                     kind = "proc" if mode == "process" else "tool"

+ 29 - 26
examples/process_pipeline/script/llm_evaluate_sources.py

@@ -54,8 +54,6 @@ _PROMPT_TEMPLATE_CACHE: Optional[Dict[str, str]] = None
 # mod.md 风格的中文 schema 知识类型枚举值(取代了旧 英文 procedure/step/tool)
 _VALID_KNOWLEDGE_TYPES = {"工序", "能力", "工具"}
 _MAX_BODY_CHARS = 8000      # 控制单帖 prompt token:正文/字幕截断上限
-_MAX_COMMENTS = 20          # 评论最多带多少条(喂"评论反馈"维度)
-_MAX_COMMENT_CHARS = 200    # 单条评论截断上限
 
 # 上次评估的产物字段——dump source JSON 给 LLM 时必须剥掉,
 # 否则 LLM 会"先验 anchoring"到旧分数,新评估失真。
@@ -145,30 +143,14 @@ def _extract_author(post: Dict[str, Any]) -> str:
     )
 
 
-def _extract_comments(source: Dict[str, Any]) -> List[str]:
-    """从 source.comments 抽出评论文本,截断条数与长度。"""
-    raw = source.get("comments") or []
-    out: List[str] = []
-    for c in raw[:_MAX_COMMENTS]:
-        if isinstance(c, dict):
-            text = c.get("content") or c.get("text") or c.get("comment") or ""
-        else:
-            text = str(c)
-        text = (text or "").strip()
-        if text:
-            out.append(text[:_MAX_COMMENT_CHARS])
-    return out
-
-
 def _format_post_for_eval(source: Dict[str, Any]) -> str:
     """把一条 source 序列化为 JSON 字符串供 LLM 评估。
 
     现代 LLM 读结构化 JSON 比读"标签:值"自然语言更准——字段名直接告诉它语义,
     不需要靠 prompt 工程把字段标签写漂亮。所以直接 dump source 整段。
 
-    保留两处截断防 token 爆:
+    正文做一处截断防 token 爆:
       - post.body_text 截到 _MAX_BODY_CHARS(个别帖子正文几万字)
-      - comments 数量截到 _MAX_COMMENTS
     其余字段全量给 LLM,由它自行判断哪些有用(如 images URL / channel_account_id 等)。
     """
     # 浅拷贝避免修改调用方的 source
@@ -180,12 +162,6 @@ def _format_post_for_eval(source: Dict[str, Any]) -> str:
         post["body_text"] = body[:_MAX_BODY_CHARS] + f"\n…(正文已截断,原长 {len(body)} 字)"
     s["post"] = post
 
-    comments = s.get("comments") or []
-    if isinstance(comments, list) and len(comments) > _MAX_COMMENTS:
-        s["comments"] = comments[:_MAX_COMMENTS] + [
-            {"_note": f"(评论已截断,共 {len(comments)} 条,只发前 {_MAX_COMMENTS})"}
-        ]
-
     # 剥掉两类不该进 prompt 的字段:
     #   ① `_` 前缀内部字段(如 _quality_grade / _image_data_urls)——管线元数据,无信息量
     #   ② _EVAL_PRODUCT_FIELDS(如 llm_evaluation / images_sent)——上次评估的产物,
@@ -308,7 +284,34 @@ def _validate_eval(data: Dict[str, Any]) -> Optional[str]:
         return "质量 必须是对象"
     if not isinstance(q.get("固定维度"), dict) and not isinstance(q.get("动态维度"), dict):
         return "质量 必须至少包含 固定维度 或 动态维度"
-    # 子字段不强校验, 由 prompt 引导 + 前端兜底; 这层保 batch 不被重试拖死
+
+    # ── 采纳门槛依据维度必填(其余子字段仍不强校验)──────────────────────────────
+    # 背景:部分模型(实测 gemini-3.5-flash)会静默漏掉判罚维度。校验放行后 _repro_score
+    # 取不到值→门槛失效→低质帖被误采纳。故对门槛依据的两维强校验,缺失/非数字即触发重试:
+    #   · 意图可控性(固定维度)
+    #   · 实现完整性(动态维度·命中的 工序/能力·字段完整性) —— 承载可复现性封顶规则
+    fixed = q.get("固定维度") if isinstance(q.get("固定维度"), dict) else {}
+    dyn = q.get("动态维度") if isinstance(q.get("动态维度"), dict) else {}
+
+    def _need_score(node, path):
+        if isinstance(node, dict):
+            node = node.get("得分")
+        try:
+            v = float(node)
+        except (TypeError, ValueError):
+            return f"{path}.得分 缺失或非数字(必填,采纳门槛依据)"
+        return None if 0 <= v <= 10 else f"{path}.得分 必须在 0-10, 得到 {node!r}"
+
+    err = _need_score(fixed.get("意图可控性"), "质量.固定维度.意图可控性")
+    if err:
+        return err
+    for t in data["知识类型"]:
+        if t not in ("工序", "能力"):
+            continue
+        impl = ((dyn.get(t) or {}).get("字段完整性") or {}).get("实现完整性")
+        err = _need_score(impl, f"质量.动态维度.{t}.字段完整性.实现完整性")
+        if err:
+            return err
     return None
 
 

+ 27 - 43
examples/process_pipeline/script/search_eval/eval_prompt_template.md

@@ -1,18 +1,33 @@
 # 评估 prompt 模板(mod.md 风格的单一源 / single source of truth)
+
 #
+
 # 块分隔符:`=== BLOCK_NAME ===` 或 `# BLOCK_NAME` markdown H1 标题(BLOCK_NAME 是大写英文 token)
+
 # 第一个分隔符之前是文件头注释,运行时跳过。块内所有行字面保留。
+
 #
+
 # 占位符:`{query}` / `{post_block}` —— 代码用 str.replace() 替换(不走 .format,因 USER 块
+
 # 含字面 JSON 大括号会触发 .format KeyError)。
+
 #
-# 拼装顺序(在 _build_eval_messages 里):
-#   system = [SYSTEM] 字面
-#   user   = [USER] 字面,{query} / {post_block} 已被 .replace 填值
-#   多模态时 image_url 数组直接挂在 user content 后(USER 块内已含『请结合配图判断』提示)。
+
+# 拼装顺序(在 \_build_eval_messages 里):
+
+# system = [SYSTEM] 字面
+
+# user = [USER] 字面,{query} / {post_block} 已被 .replace 填值
+
+# 多模态时 image_url 数组直接挂在 user content 后(USER 块内已含『请结合配图判断』提示)。
+
 #
-# 注: 时效性字段不在此 schema 里 —— 由 llm_evaluate_sources._calc_recency_score 用
-# publish_timestamp 直接算分, 在 _evaluate_one 内注入到 质量.固定维度.时效性.{得分},
+
+# 注: 时效性字段不在此 schema 里 —— 由 llm_evaluate_sources.\_calc_recency_score 用
+
+# publish_timestamp 直接算分, 在 \_evaluate_one 内注入到 质量.固定维度.时效性.{得分},
+
 # 省 token 且更稳定。LLM 不要输出时效性, 此字段输出会被覆盖。
 
 === SYSTEM ===
@@ -20,6 +35,8 @@
 
 评分时须始终牢记:所有「成品」「效果」「用例」均指 AI 生成的图片或视频;帖子的核心价值必须体现在视觉内容的生产过程上。纯文字输出、代码生成、论文写作、生活记录等场景不属于本管线范围,相关维度直接给低分。
 
+各维度必须独立打分:每个维度只能依据该维度自身对应的直接证据判断,禁止用「帖子整体质量高/低」的印象或其他维度的结论去连带拉高/拉低本维度得分,也禁止据此预测或假设当下不存在的证据(例如不能因为内容质量好就推测某个缺失证据的维度也应得高分)。证据不存在时,该维度就应给低分,不允许用其他维度补偿。
+
 === USER ===
 **【检索词】**
 `{query}`
@@ -32,7 +49,6 @@
 | 类型   | 定义                                                                                                                  | 例                                                       |
 | ------ | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
 | `工序` | 端到端多步流程,最终目标是产出 AI 图片或视频。仅当整条流程的终点是生成/处理视觉内容时才归为工序,否则归入对应工具类型 | "用 AI 做营销海报:写 prompt→生成素材→局部重绘→导出成品" |
-| `能力` | 单个原子操作怎么实现,且该操作直接影响图/视频最终长什么样                                                             | "抠图怎么留发丝"                                         |
 | `工具` | 某具体工具怎么用                                                                                                      | "nanobanana 参数与能力边界"                              |
 
 一帖可多标签,各类型分别评分。**流程:先分类 → 再套对应维度打分。**
@@ -41,7 +57,7 @@
 制作相关的内容,必须同时满足三个条件:
 
 1. 有明确的视觉意图
-   创作者在这篇内容里,目标是产出图或视频
+   创作者在这篇内容里,目标是基于创作的具体需求产出图或视频
 
 2. AI 是生产工具
    图/视频是通过 AI 使用工具生成或处理的,不是拍摄、手绘、截图
@@ -80,11 +96,7 @@
   "质量": {
     "固定维度": {
       "热度性": {
-        "得分": "0-10。综合点赞/收藏/评论量判断热度",
-        "理由": "中文"
-      },
-      "评论反馈": {
-        "得分": "0-10。评论区正负反馈综合判断",
+        "得分": "0-10。综合点赞/收藏量判断热度",
         "理由": "中文"
       },
       "用例": {
@@ -97,10 +109,6 @@
           "理由": "中文"
         }
       },
-      "可复现性": {
-        "得分": "0-10。照着做能否稳定复现出相当效果:决定成品好坏的关键变量,是否由教程里教的可控手段(具体 prompt/参数/控制条件/可量化的判断标准)掌控。**硬封顶规则(优先于一切,无论其余步骤描述得多清晰、流程多完整):只要决定成品长相的关键创作动作(写 prompt / 选风格 / 控构图 / 挑模板)被甩给『自己看着改』『按需求调』,或外包给另一个黑盒(『发给豆包/DeepSeek 让它写』),或依赖教程没教你如何获得的不可控输入(现成的好模板/好底图、个人审美、靠抽卡运气)——可复现性 ≤4。** 注意区分:『把每一步点哪个按钮说清楚』是步骤清晰,不等于可复现;真正可复现要求决定效果的那个创作变量本身被教成可控的。",
-        "理由": "中文"
-      },
       "意图可控性": {
         "得分": "0-10。用户对成品的视觉效果有自己的预期。这条知识能否让用户朝自己想要的方向去调、可达的视觉范围有多宽。高分:讲清了哪些变量(prompt/参数/风格/构图/控制条件)如何影响成品长相,用户能据此把效果调向自己的目标、覆盖多种风格题材。**硬封顶规则(优先于一切):若成品被锁死在某个固定模板/单一风格上、用户只能复刻作者那一种结果、想换个风格/版式就无从下手(如『套这个模板就能出』但不教怎么改方向),或对效果的精确控制只能依赖模型本身发挥而非教程给出的可调手段——意图可控性 ≤4。** 无成品或纯展示无方法→低分。",
         "理由": "中文"
@@ -119,38 +127,13 @@
             "理由": "中文"
           },
           "实现完整性": {
-            "得分": "0-10。每步的具体操作(工具/参数/prompt)是否说清楚",
+            "得分": "0-10。每步的具体操作(工具/参数/prompt)是否说清楚;同时考察照着做能否稳定复现出相当效果——决定成品好坏的关键变量是否由教程教的可控手段(具体 prompt/参数/控制条件/可量化判断标准)掌控。**硬封顶规则(优先于一切,无论步骤描述得多清晰、流程多完整):只要决定成品长相的关键创作动作(写 prompt / 选风格 / 控构图 / 挑模板)被甩给『自己看着改』『按需求调』,或外包给另一个黑盒(『发给豆包/DeepSeek 让它写』),或依赖教程没教你如何获得的不可控输入(现成的好模板/好底图、个人审美、靠抽卡运气)——实现完整性 ≤4。** 注意区分:『把每一步点哪个按钮说清楚』是步骤清晰,不等于可复现;真正的实现完整要求决定效果的那个创作变量本身被教成可控的。",
             "理由": "中文"
           },
           "输出完整性": {
             "得分": "0-10。每步的产出物及其格式/标准是否明确",
             "理由": "中文"
           }
-        },
-        "泛化性": {
-          "得分": "0-10。这套『方法/流程』本身能否搬去做别的题材或换别的工具,还是只适用于特定单例(只评方法可移植性;成品能不能按预期调由『意图可控性』负责,勿混)",
-          "理由": "中文"
-        }
-      },
-
-      "能力": {
-        "字段完整性": {
-          "输入完整性": {
-            "得分": "0-10。该原子操作的触发条件/所需输入是否交代清楚",
-            "理由": "中文"
-          },
-          "实现完整性": {
-            "得分": "0-10。是否真讲 HOW(参数/技法/设置),非一句带过",
-            "理由": "中文"
-          },
-          "输出完整性": {
-            "得分": "0-10。操作结果/输出标准是否明确,是否说明何时 work/不 work",
-            "理由": "中文"
-          }
-        },
-        "泛化性": {
-          "得分": "0-10。是工具无关的通法,还是绑死某一工具/版本",
-          "理由": "中文"
         }
       },
 
@@ -188,6 +171,7 @@
 {post_block}
 
 **【注意】**
+
 - 每个维度都要 `得分` + `理由`(中文 1-2 句简述:基于帖子哪段内容/哪张图给的这个分)
 - `动态维度` 里只填命中 `知识类型` 的类型块,未命中的整块省略
 - 若附了配图,请结合图片判断真实感/表现力

+ 190 - 0
examples/process_pipeline/script/search_eval/eval_prompt_template2.md

@@ -0,0 +1,190 @@
+# 评估 prompt 模板(mod.md 风格的单一源 / single source of truth)
+#
+# 块分隔符:`=== BLOCK_NAME ===` 或 `# BLOCK_NAME` markdown H1 标题(BLOCK_NAME 是大写英文 token)
+# 第一个分隔符之前是文件头注释,运行时跳过。块内所有行字面保留。
+#
+# 占位符:`{query}` / `{post_block}` —— 代码用 str.replace() 替换(不走 .format,因 USER 块
+# 含字面 JSON 大括号会触发 .format KeyError)。
+#
+# 拼装顺序(在 _build_eval_messages 里):
+#   system = [SYSTEM] 字面
+#   user   = [USER] 字面,{query} / {post_block} 已被 .replace 填值
+#   多模态时 image_url 数组直接挂在 user content 后(USER 块内已含『请结合配图判断』提示)。
+#
+# 注: 时效性字段不在此 schema 里 —— 由 llm_evaluate_sources._calc_recency_score 用
+# publish_timestamp 直接算分, 在 _evaluate_one 内注入到 质量.固定维度.时效性.{得分},
+# 省 token 且更稳定。LLM 不要输出时效性, 此字段输出会被覆盖。
+
+=== SYSTEM ===
+你是内容采集管线里的知识质量评估器,专门服务于「AI 图片 / 视频制作」领域的知识采集管线。严格按要求对单条帖子做知识质量评估。只输出一个 JSON 对象,不要任何解释性文字,不要 markdown 代码块。
+
+评分时须始终牢记:所有「成品」「效果」「用例」均指 AI 生成的图片或视频;帖子的核心价值必须体现在视觉内容的生产过程上。纯文字输出、代码生成、论文写作、生活记录等场景不属于本管线范围,相关维度直接给低分。
+
+=== USER ===
+**【检索词】**
+`{query}`
+判断相关性时:这帖是否真的在回答这个检索词的意图。
+
+---
+
+**【知识类型分类】**
+
+| 类型   | 定义                                                                                                                  | 例                                                       |
+| ------ | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
+| `工序` | 端到端多步流程,最终目标是产出 AI 图片或视频。仅当整条流程的终点是生成/处理视觉内容时才归为工序,否则归入对应工具类型 | "用 AI 做营销海报:写 prompt→生成素材→局部重绘→导出成品" |
+| `能力` | 单个原子操作怎么实现,且该操作直接影响图/视频最终长什么样                                                             | "抠图怎么留发丝"                                         |
+| `工具` | 某具体工具怎么用                                                                                                      | "nanobanana 参数与能力边界"                              |
+
+一帖可多标签,各类型分别评分。**流程:先分类 → 再套对应维度打分。**
+
+**制作相关内容定义**
+制作相关的内容,必须同时满足三个条件:
+
+1. 有明确的视觉意图
+   创作者在这篇内容里,目标是产出图或视频
+
+2. AI 是生产工具
+   图/视频是通过 AI 使用工具生成或处理的,不是拍摄、手绘、截图
+
+3. 存在可迁移的制作知识
+   制作知识:内容传递了「怎么做出这张图/视频」的知识
+   核心判断:这个知识点是在影响图/视频最终长什么样吗?
+   是 → 属于制作知识,包括但不限于:
+   prompt 写法与优化、反推提示词、风格控制
+   模型/Lora 选配与权重调节、采样器与参数设置
+   图生图、局部重绘、ControlNet 条件控制
+   视频帧间控制、运镜、时序一致性处理
+   否 → 不属于制作知识,包括:
+   让工具能跑起来的一切操作(下载、安装、部署、训练、微调)
+   纯粹的结果展示而没有任何方法
+
+---
+
+**【输出 schema(按此结构输出,字段不得增减/改名)】**
+
+```json
+{
+  "知识类型": ["工序 | 能力 | 工具(可多个)"],
+
+  "相关性": {
+    "和内容制作知识相关": {
+      "得分": "0-10。内容是否落在『制作』范围内,是否包含可迁移的制作知识",
+      "理由": "中文"
+    },
+    "和 query 相关": {
+      "得分": "0-10。内容是否真的回答检索词意图",
+      "理由": "中文"
+    }
+  },
+
+  "质量": {
+    "固定维度": {
+      "热度性": {
+        "得分": "0-10。综合点赞/收藏量判断热度",
+        "理由": "中文"
+      },
+      "用例": {
+        "真实感": {
+          "得分": "0-10。评估 AI 生成图/视频的视觉质量:画面越自然、越难看出 AI 痕迹→分越高;AI感明显(手指异常、光影错误、纹理重复、文字变形等)→分越低;无成品展示→0分。",
+          "理由": "中文"
+        },
+        "表现力": {
+          "得分": "0-10。展示出的制作成品视觉表现力,能否发到社媒平台,无用例则为 0 分",
+          "理由": "中文"
+        }
+      },
+      "可复现性": {
+        "得分": "0-10。照着做能否稳定复现出相当效果:决定成品好坏的关键变量,是否由教程里教的可控手段(具体 prompt/参数/控制条件/可量化的判断标准)掌控。**硬封顶规则(优先于一切,无论其余步骤描述得多清晰、流程多完整):只要决定成品长相的关键创作动作(写 prompt / 选风格 / 控构图 / 挑模板)被甩给『自己看着改』『按需求调』,或外包给另一个黑盒(『发给豆包/DeepSeek 让它写』),或依赖教程没教你如何获得的不可控输入(现成的好模板/好底图、个人审美、靠抽卡运气)——可复现性 ≤4。** 注意区分:『把每一步点哪个按钮说清楚』是步骤清晰,不等于可复现;真正可复现要求决定效果的那个创作变量本身被教成可控的。",
+        "理由": "中文"
+      },
+      "意图可控性": {
+        "得分": "0-10。用户对成品的视觉效果有自己的预期。这条知识能否让用户朝自己想要的方向去调、可达的视觉范围有多宽。高分:讲清了哪些变量(prompt/参数/风格/构图/控制条件)如何影响成品长相,用户能据此把效果调向自己的目标、覆盖多种风格题材。**硬封顶规则(优先于一切):若成品被锁死在某个固定模板/单一风格上、用户只能复刻作者那一种结果、想换个风格/版式就无从下手(如『套这个模板就能出』但不教怎么改方向),或对效果的精确控制只能依赖模型本身发挥而非教程给出的可调手段——意图可控性 ≤4。** 无成品或纯展示无方法→低分。",
+        "理由": "中文"
+      }
+    },
+
+    "动态维度": {
+      "工序": {
+        "流程完整性": {
+          "得分": "0-10。流程是否端到端齐全、有没有断档,起点到产出图/视频是否闭环",
+          "理由": "中文"
+        },
+        "字段完整性": {
+          "输入完整性": {
+            "得分": "0-10。每个步骤所需的输入条件是否交代清楚",
+            "理由": "中文"
+          },
+          "实现完整性": {
+            "得分": "0-10。每步的具体操作(工具/参数/prompt)是否说清楚",
+            "理由": "中文"
+          },
+          "输出完整性": {
+            "得分": "0-10。每步的产出物及其格式/标准是否明确",
+            "理由": "中文"
+          }
+        },
+        "泛化性": {
+          "得分": "0-10。这套『方法/流程』本身能否搬去做别的题材或换别的工具,还是只适用于特定单例(只评方法可移植性;成品能不能按预期调由『意图可控性』负责,勿混)",
+          "理由": "中文"
+        }
+      },
+
+      "能力": {
+        "字段完整性": {
+          "输入完整性": {
+            "得分": "0-10。该原子操作的触发条件/所需输入是否交代清楚",
+            "理由": "中文"
+          },
+          "实现完整性": {
+            "得分": "0-10。是否真讲 HOW(参数/技法/设置),非一句带过",
+            "理由": "中文"
+          },
+          "输出完整性": {
+            "得分": "0-10。操作结果/输出标准是否明确,是否说明何时 work/不 work",
+            "理由": "中文"
+          }
+        },
+        "泛化性": {
+          "得分": "0-10。是工具无关的通法,还是绑死某一工具/版本",
+          "理由": "中文"
+        }
+      },
+
+      "工具": {
+        "能力边界覆盖": {
+          "得分": "0-10。是否说清工具能做/不能做什么",
+          "理由": "中文"
+        },
+        "有效比较": {
+          "得分": "0-10。工具A比B在XX方面做得更好,这样能帮我们更好地选工具;如果只是泛泛地说这个工具能干什么,对我们选工具帮助就有限",
+          "理由": "中文"
+        },
+        "参数/接口具体性": {
+          "得分": "0-10。是否给出具体参数/选项/命令/输入输出格式",
+          "理由": "中文"
+        },
+        "实操示例": {
+          "得分": "0-10。是否有真实 input→output 示例及所用参数",
+          "理由": "中文"
+        },
+        "版本&限制": {
+          "得分": "0-10。是否说明版本号/额度/质量/合规限制/时效",
+          "理由": "中文"
+        }
+      }
+    }
+  }
+}
+```
+
+---
+
+**【待评估帖子(原始 JSON)】**
+
+{post_block}
+
+**【注意】**
+- 每个维度都要 `得分` + `理由`(中文 1-2 句简述:基于帖子哪段内容/哪张图给的这个分)
+- `动态维度` 里只填命中 `知识类型` 的类型块,未命中的整块省略
+- 若附了配图,请结合图片判断真实感/表现力
+- 只输出 JSON,不要其他内容

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

@@ -229,7 +229,6 @@ def adapt(r, run, form_name=None):
         fixed_keys = {
             "时效性": "recency",
             "热度性": "popularity",
-            "评论反馈": "feedback"
         }
         for cn, code in fixed_keys.items():
             item = fixed.get(cn)

+ 0 - 1
examples/process_pipeline/script/search_eval/mode_procedure/server.py

@@ -350,7 +350,6 @@ def adapt(r, run, form_name=None):
         fixed_keys = {
             "时效性": "recency",
             "热度性": "popularity",
-            "评论反馈": "feedback"
         }
         for cn, code in fixed_keys.items():
             item = fixed.get(cn)

+ 8 - 4
examples/process_pipeline/script/search_eval/search_and_evaluate.py

@@ -469,10 +469,14 @@ async def evaluate_posts(
         lo, hi = escalate_band
 
         def _band_hit(ev):
-            """可复现性 / 意图可控性 任一落在 [lo,hi] 闭区间 → 需升级。"""
-            fixed = (((ev or {}).get("质量") or {}).get("固定维度") or {})
-            for name in ("可复现性", "意图可控性"):
-                v = fixed.get(name)
+            """可复现性/实现完整性 / 意图可控性 任一落在 [lo,hi] 闭区间 → 需升级。
+            兼容新旧 schema:旧版「可复现性」在固定维度,新版「实现完整性」在动态维度.工序.字段完整性。"""
+            q = ((ev or {}).get("质量") or {})
+            fixed = q.get("固定维度") or {}
+            impl = (((q.get("动态维度") or {}).get("工序") or {})
+                    .get("字段完整性") or {}).get("实现完整性")
+            candidates = [fixed.get("可复现性"), fixed.get("意图可控性"), impl]
+            for v in candidates:
                 if isinstance(v, dict):
                     v = v.get("得分")
                 try:

+ 0 - 1
examples/process_pipeline/script/search_eval/server.py

@@ -244,7 +244,6 @@ def adapt(r, run, form_name=None):
         fixed_keys = {
             "时效性": "recency",
             "热度性": "popularity",
-            "评论反馈": "feedback"
         }
         for cn, code in fixed_keys.items():
             item = fixed.get(cn)