From c969fa637d2c513b82f295d51275b8969dacce56 Mon Sep 17 00:00:00 2001 From: zizi Date: Mon, 20 Jul 2026 22:12:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=94=A8=E5=8E=9F?= =?UTF-8?q?=E6=96=87=E8=AF=81=E6=8D=AE=E7=BB=91=E5=AE=9A=E5=8D=87=E6=A0=BC?= =?UTF-8?q?=E9=87=8C=E7=A8=8B=E7=A2=91=E7=AB=A0=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/parse-book/SKILL.md | 2 +- .../parse-book/scripts/parse_upgrade.py | 236 ++++++++++++++++-- .../scripts/test_parse_upgrade_offline.py | 140 +++++++++++ .claude/skills/replay-eval/SKILL.md | 2 +- docs/2026-07-16-升格卡改造设计.md | 19 +- .../2026-07-19-回放评测-细纲首跑设计与计划.md | 11 +- 6 files changed, 373 insertions(+), 37 deletions(-) diff --git a/.claude/skills/parse-book/SKILL.md b/.claude/skills/parse-book/SKILL.md index cf6d830..4145aa7 100644 --- a/.claude/skills/parse-book/SKILL.md +++ b/.claude/skills/parse-book/SKILL.md @@ -58,7 +58,7 @@ disable-model-invocation: true **窗行陷阱(放量首日实测)**:`--window` 参数变化后重切,旧窗行会按 from_order 占位,新的大窗被「已有大纲跳过」→ 中间章域永远漏出卡(验收期 1–3 章小窗占住 from_order=1,放量 1–34 章大窗被跳过)。**换窗参数重切前必须先删该书全部窗行**(窗行是可再生中间产物;卡挂「窗起」,cards 重出时按窗软删重出)。 -**作品面升格执行器 `scripts/parse_upgrade.py`(与上面范式拆书管线并行的另一条线,命令 `windows`/`run`/`status`)**:把参考书正文按窗抽成「会随剧情长大的实体卡」(升格卡,`source_type=upgrade_book`),设计见 `docs/2026-07-16-升格卡改造设计.md`。`run` 默认**不发嵌入**;加 `--semantic-dedup` 开语义判重(治改名/跨型漏并)时,每窗按**读→算→写三段式短连接**跑——观察/近邻召回/M3 终判都在**无长连接**段发 LLM 与嵌入 HTTP(不再持窗级连接跨调用存活),写段只查预判结果落库;并**边抽边嵌**:本窗新建/更新卡在窗事务提交后增量嵌入落库(软删旧向量+upsert 新行),**后窗即可语义召回前窗刚长成的卡**,不再依赖"同书须预先全量 embed"。嵌段失败只告警、不牵连已 done 的窗。 +**作品面升格执行器 `scripts/parse_upgrade.py`(与上面范式拆书管线并行的另一条线,命令 `windows`/`run`/`status`)**:把参考书正文按窗抽成「会随剧情长大的实体卡」(升格卡,`source_type=upgrade_book`),设计见 `docs/2026-07-16-升格卡改造设计.md`。里程碑除真实章号外,模型输出必须临时携带所标章节正文短引;系统机械核验后删除证据,错章、缺证据或改写证据均拒收入库并留审计。`run` 默认**不发嵌入**;加 `--semantic-dedup` 开语义判重(治改名/跨型漏并)时,每窗按**读→算→写三段式短连接**跑——观察/近邻召回/M3 终判都在**无长连接**段发 LLM 与嵌入 HTTP(不再持窗级连接跨调用存活),写段只查预判结果落库;并**边抽边嵌**:本窗新建/更新卡在窗事务提交后增量嵌入落库(软删旧向量+upsert 新行),**后窗即可语义召回前窗刚长成的卡**,不再依赖"同书须预先全量 embed"。嵌段失败只告警、不牵连已 done 的窗。 ## 步骤(自底向上,与创作期规划的自顶向下互为镜像) diff --git a/.claude/skills/parse-book/scripts/parse_upgrade.py b/.claude/skills/parse-book/scripts/parse_upgrade.py index 1918046..24df440 100644 --- a/.claude/skills/parse-book/scripts/parse_upgrade.py +++ b/.claude/skills/parse-book/scripts/parse_upgrade.py @@ -27,6 +27,7 @@ import json import pathlib import re import sys +import unicodedata import click import psycopg @@ -132,16 +133,26 @@ def cut_windows(conn, work_id): # ── 每窗材料与已知名加载 ── -def load_window_text(conn, work_id, a, b): - """窗内正文拼接:第N章《标题》+正文。""" +def load_window_material(conn, work_id, a, b): + """返回窗正文拼接文本与按绝对章号索引的原始正文。""" + rows = conn.execute( """SELECT c.order_no, c.title, b2.content_text FROM muse_content_chapter c JOIN muse_content_block b2 ON b2.chapter_id=c.id AND b2.deleted=FALSE WHERE c.tenant_id=%s AND c.work_id=%s AND c.order_no BETWEEN %s AND %s AND c.deleted=FALSE ORDER BY c.order_no""", - (TENANT, work_id, a, b)).fetchall() - return "\n\n".join(f"## 第{o}章 {t}\n{x}" for o, t, x in rows) + (TENANT, work_id, a, b)).fetchall() + return ( + "\n\n".join(f"## 第{o}章 {t}\n{x}" for o, t, x in rows), + {int(order_no): str(content or "") for order_no, _, content in rows}, + ) + + +def load_window_text(conn, work_id, a, b): + """兼容旧调用:只返回带章标题的窗内正文拼接文本。""" + + return load_window_material(conn, work_id, a, b)[0] def load_known(conn, work_id): @@ -149,16 +160,20 @@ def load_known(conn, work_id): 返回 (name_map: {名字: (draft_id, 型, 摘要)}, presence: {(型,名字): set(章)})""" name_map = {} - for did, payload in conn.execute( - """SELECT id, draft_payload FROM muse_knowledge_draft + for did, entity_type, name, brief, aliases in conn.execute( + """SELECT id, + draft_payload->>'type', + draft_payload->>'名称', + COALESCE(draft_payload->>'一句话摘要',''), + COALESCE(draft_payload->'别名','[]'::jsonb) + FROM muse_knowledge_draft WHERE tenant_id=%s AND work_id=%s AND source_type=%s AND deleted=FALSE""", (TENANT, work_id, SOURCE_TYPE)).fetchall(): - t, nm = payload.get("type", ""), (payload.get("名称") or "").strip() - brief = payload.get("一句话摘要", "") + t, nm = entity_type or "", (name or "").strip() if nm: name_map[nm] = (did, t, brief) - for al in payload.get("别名", []) or []: - if al and al.strip(): + for al in aliases or []: + if isinstance(al, str) and al.strip(): name_map[al.strip()] = (did, t, brief) for cn, al in conn.execute( "SELECT canonical_name, alias FROM example_upgrade_alias " @@ -195,7 +210,7 @@ def observe_prompt(contracts, title, a, b, text, onstage): 2) 已知实体新信息:清单中实体在本窗的实质新信息(境界变化/性格显露/重大经历/立场转变),一条 ≤60 字观察点。没有实质新信息的不要报。 3) 纯出场:清单中实体本窗出现但无实质新信息的,只报名称+出场章。 纪律:一次性龙套(单章无名或仅路过)不报进新名字;实体判据与字段以合同为准,无证据不填;不脑补。数值(战力/指数/排名等)必须正文原样出现才可写,禁止推算或编造。组织改组/合并产生的新组织是**新实体**(走新名字),不是旧组织的别名。「疑似别名指向」只在确为同一实体改名/化名时填。 -里程碑纪律(凡填「演变历程」字段必守):每条是对象 {{"章": 正文原样出现的真实章号(整数如 420,跨多章连续事件用区间字符串如 "420-423"), "台阶": "进化到什么+靠什么关键事件的一句话(≤40 字,只说跃迁+关键事件,不展开过程)", "周期": 登场/成长/高光/退场/结局 之一}};**必须用真实章号,禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代**(卡会脱离运行环境被单独阅读);抽不出完整对象时至少给 {{"章","台阶"}},别整条丢;当前态字段(品阶/能力与限制/摘要等)只写"现在什么样"的干净值,历史进化流水一律进「演变历程」、不许塞进当前态字段。**体系级纪律(power_system/faction 这类体系/组织卡必守)**:其「演变历程」只收**体系级**变化(代际更替/规则改写/整体兴衰/版图重划);某台具体机甲的战斗、某名成员的晋升这类**个体单位事件**绝不写进体系/组织卡,只记进各自实体卡(无卡则走新名字或纯出场),防止体系卡被单位流水账污染。 +里程碑纪律(凡填「演变历程」字段必守):每条是对象 {{"章": 正文原样出现的真实章号(整数如 420,跨多章连续事件用区间字符串如 "420-423"), "台阶": "进化到什么+靠什么关键事件的一句话(≤40 字,只说跃迁+关键事件,不展开过程)", "周期": 登场/成长/高光/退场/结局 之一, "证据": "所标章节正文原样连续短句(8–30字)"}};**证据必须逐字来自所填章号对应正文,系统会机械核验,错章、缺证据或改写证据均拒收入库;证据只用于校验,不写入卡体**。必须用真实章号,禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代(卡会脱离运行环境被单独阅读);无法给出章号与原文证据时不要输出该里程碑;当前态字段(品阶/能力与限制/摘要等)只写"现在什么样"的干净值,历史进化流水一律进「演变历程」、不许塞进当前态字段。**体系级纪律(power_system/faction 这类体系/组织卡必守)**:其「演变历程」只收**体系级**变化(代际更替/规则改写/整体兴衰/版图重划);某台具体机甲的战斗、某名成员的晋升这类**个体单位事件**绝不写进体系/组织卡,只记进各自实体卡(无卡则走新名字或纯出场),防止体系卡被单位流水账污染。 【六型字段合同】 {render_entity_contracts(contracts, ENTITY_TYPES)} @@ -220,7 +235,7 @@ def update_prompt(contracts, title, a, b, text, cards_with_obs): return f"""【功能指令(parse-book 作品面升格·卡增量更新)】 下列每张卡给出「当前卡全文」与「本窗观察点」。对照本窗正文,**只输出需要变更的字段**: - 覆写类字段(性格底色/说话方式/当前状态等标量):**必须输出该字段完整的新全量值**——旧值里仍然成立的信息要保留进新值,禁止只写"新增…"式增量(那会把旧信息抹掉); -- 里程碑字段(演变历程):每当实体发生境界/代际/形态/能力的跃迁,或到达登场/高光/退场/结局节点,**追加**一条里程碑对象 {{"章": 真实章号(整数如 420 或跨章区间字符串 "420-423"), "台阶": "进化到什么+靠什么关键事件的一句话(≤40 字,只说跃迁+关键事件,不展开过程)", "周期": 登场/成长/高光/退场/结局 之一}}——只输出本窗**新增**里程碑(不重抄旧条目,不输出 _win 等内部键);章必须是正文原样章号、**禁 [窗N] 与"本窗/近期"相对指代**;抽不出完整对象时至少给 {{"章","台阶"}},别整条丢;并入观察材料里的别名/判重「初卡材料」时,其中「登场」条目**若本卡演变历程已有登场里程碑则不再重复追加**,其余台阶照常判断追加;**体系级纪律**:power_system/faction 这类体系/组织卡的演变历程只收体系级变化(代际更替/规则改写/整体兴衰/版图重划),某台具体机甲的战斗、某名成员的晋升这类个体单位事件绝不写进体系/组织卡; +- 里程碑字段(演变历程):每当实体发生境界/代际/形态/能力的跃迁,或到达登场/高光/退场/结局节点,**追加**一条里程碑对象 {{"章": 真实章号(整数如 420 或跨章区间字符串 "420-423"), "台阶": "进化到什么+靠什么关键事件的一句话(≤40 字,只说跃迁+关键事件,不展开过程)", "周期": 登场/成长/高光/退场/结局 之一, "证据": "所标章节正文原样连续短句(8–30字)"}}——只输出本窗**新增**里程碑(不重抄旧条目,不输出 _win 等内部键);证据必须逐字来自所填章号对应正文,系统机械核验后丢弃证据字段,错章、缺证据或改写证据均拒收;章必须是正文原样章号、**禁 [窗N] 与"本窗/近期"相对指代**;无法给出章号与证据时不要输出该里程碑;并入观察材料里的别名/判重「初卡材料」时,其中「登场」条目**若本卡演变历程已有登场里程碑则不再重复追加**,其余台阶照常判断追加;**体系级纪律**:power_system/faction 这类体系/组织卡的演变历程只收体系级变化(代际更替/规则改写/整体兴衰/版图重划),某台具体机甲的战斗、某名成员的晋升这类个体单位事件绝不写进体系/组织卡; - 其他追加类字段(成长弧线=未来计划、演变轨迹等数组):只输出本窗新增条目(不要重抄旧条目,不要自己加 [窗N] 前缀,系统会加); - **当前态字段干净纪律**:品阶/能力与限制/当前状态/一句话摘要等只写"现在是什么样"的全量当前值——历史进化流水一律进「演变历程」里程碑,**绝不把成长史塞进当前态字段**(这是老卡把升级线塞进「戏剧作用/流转计划/跨体系换算」污染字段语义的病根,务必避免); - 没有变化的字段不要输出;整卡无实质变化则不输出该卡; @@ -390,12 +405,128 @@ def _infer_lifecycle(text): return "成长" -def _clean_milestone(item, win_no): +def _milestone_chapters(value): + """把单章或闭区间章号转为整数列表;含糊格式直接返回空列表。""" + + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return [value] + if not isinstance(value, str): + return [] + match = re.fullmatch(r"\s*(\d+)\s*(?:[-~—–至]\s*(\d+)\s*)?", value) + if not match: + return [] + start = int(match.group(1)) + end = int(match.group(2) or start) + if start <= 0 or end < start or end - start > 50: + return [] + return list(range(start, end + 1)) + + +def _normalize_evidence_text(value): + """证据比对忽略排版空白和宽窄差异,但保留正文字符与标点。""" + + return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(value or ""))) + + +def _milestone_evidence_matches(item, chapter_texts): + """验证短原文证据确实存在于里程碑声明的章节正文。""" + + if not isinstance(item, dict) or not isinstance(chapter_texts, dict): + return False + evidence = _normalize_evidence_text(item.get("证据")) + if not 8 <= len(evidence) <= 30: + return False + chapters = _milestone_chapters(item.get("章")) + if not chapters or any(chapter not in chapter_texts for chapter in chapters): + return False + return any( + evidence in _normalize_evidence_text(chapter_texts[chapter]) + for chapter in chapters + ) + + +def _walk_milestone_items(value, path="$"): + """稳定遍历模型输出中的里程碑对象,并生成可回填的 JSON 路径。""" + + if isinstance(value, dict): + if "章" in value and ("台阶" in value or "阶" in value): + yield path, value + return + for key, child in value.items(): + yield from _walk_milestone_items(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + yield from _walk_milestone_items(child, f"{path}[{index}]") + + +def repair_missing_milestone_evidence( + model_output, + *, + title, + a, + b, + text, + chapter_texts, + call, +): + """为本窗缺证据里程碑补原文短引;窗外重抄项不进入修复调用。""" + + candidates = [] + by_ref = {} + for ref, item in _walk_milestone_items(model_output): + if _milestone_evidence_matches(item, chapter_texts): + continue + chapters = _milestone_chapters(item.get("章")) + if not chapters or any(chapter not in chapter_texts for chapter in chapters): + continue + candidates.append( + { + "ref": ref, + "章": item.get("章"), + "台阶": item.get("台阶") or item.get("阶"), + } + ) + by_ref[ref] = item + if not candidates: + return 0 + + prompt = f"""【功能指令(parse-book 里程碑证据修复)】 +下列候选里程碑缺少可机械验证的证据。只为确实被正文支持的候选返回一条所标章节中的原文连续短句(8–30字);不得改章号、不得改台阶、不得概括或改写原文。找不到逐字证据的候选不要返回。 + +【输出规则(只输出一个 JSON 对象)】 +{{"证据修复":[{{"ref":"候选ref原样回传","章":489,"证据":"正文原样连续短句"}}]}} + +━━━ 候选里程碑 ━━━ +{json.dumps(candidates, ensure_ascii=False)} + +━━━ 《{title}》第 {a}–{b} 章正文 ━━━ +{text} +""" + repaired, _ = call(prompt, ("证据修复",)) + count = 0 + for row in repaired.get("证据修复") or []: + if not isinstance(row, dict): + continue + ref = str(row.get("ref") or "") + item = by_ref.get(ref) + if item is None or _milestone_chapters(row.get("章")) != _milestone_chapters(item.get("章")): + continue + item["证据"] = row.get("证据") + if _milestone_evidence_matches(item, chapter_texts): + count += 1 + else: + item.pop("证据", None) + return count + + +def _clean_milestone(item, win_no, *, chapter_texts=None, require_evidence=False): """规范化单个里程碑为 {章,台阶,周期,_win};垃圾/空台阶返回 None。 - dict 入参:取 章/台阶/周期;台阶剥前缀+剥尾残;缺章从台阶抽内嵌章号兜底;缺/非法周期启发式推断。 - 字符串入参(模型降级输出或存量迁移):整串当台阶,抽内嵌章号当章,推断周期。 - _win 盖当前窗号,仅作撤销溯源(undo_window 按它删本窗新增),不参与展示与排序。 降级保底(设计稿 §九 风险2 + 拍板#1):抽不出完整对象也退成"章号+一句话"最小对象,绝不整条丢。""" + if require_evidence and not _milestone_evidence_matches(item, chapter_texts): + return None if isinstance(item, dict): step = _strip_tail(_strip_prefix(str(item.get("台阶") or item.get("阶") or ""))) ch = item.get("章") @@ -419,7 +550,7 @@ def _clean_milestone(item, win_no): return {"章": ch, "台阶": step, "周期": cycle, "_win": win_no} -def _merge_milestones(old, items, win_no): +def _merge_milestones(old, items, win_no, *, chapter_texts=None, require_evidence=False): """合并里程碑数组:去重按台阶内容、排序按真实章号。返回 (merged, rejected)。 old 中已有对象保留其原 _win(不被本窗覆盖);新对象由 _clean_milestone 盖当前 win_no。 rejected 为被判垃圾的原始条目,交调用方留审计(对齐字符串路径的垃圾拦截)。""" @@ -427,7 +558,12 @@ def _merge_milestones(old, items, win_no): seen = {str(m.get("台阶", "")).strip() for m in kept} rejected = [] for it in (items if isinstance(items, list) else [items]): - m = _clean_milestone(it, win_no) + m = _clean_milestone( + it, + win_no, + chapter_texts=chapter_texts, + require_evidence=require_evidence, + ) if not m: rejected.append(it) continue @@ -471,7 +607,16 @@ def _debut_milestone(milestones, brief, chaps, win_no): return [debut] + list(milestones or []) -def merge_card(conn, draft_id, win_no, changes, alias_new, valid_keys=None): +def merge_card( + conn, + draft_id, + win_no, + changes, + alias_new, + valid_keys=None, + *, + chapter_texts=None, +): """按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号), 标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。 valid_keys:该型合同的合法字段 key 集——越合同 key 裁剪留审计(窗29实测模型把 @@ -525,7 +670,13 @@ def merge_card(conn, draft_id, win_no, changes, alias_new, valid_keys=None): # 不走下面处理字符串条目([窗N] 前缀)的老路径。迟到窗追加无害:对象自带章号, # 乱序由 _chapter_sort_key 排序纠正,故不设水位闸(与字符串追加同策略)。 base = fields.get(k) if isinstance(fields.get(k), list) else ([fields[k]] if fields.get(k) else []) - merged, rejected = _merge_milestones(base, v, win_no) + merged, rejected = _merge_milestones( + base, + v, + win_no, + chapter_texts=chapter_texts, + require_evidence=True, + ) for rj in rejected: # 垃圾/空台阶里程碑拒收留审计(对齐字符串路径的垃圾拦截守卫) conn.execute( @@ -618,7 +769,7 @@ def merge_card(conn, draft_id, win_no, changes, alias_new, valid_keys=None): (draft_id, payload.get("_work_id") or 0, win_no, TENANT)) -def new_card(conn, work_id, win_no, ent, milestone_types=None): +def new_card(conn, work_id, win_no, ent, milestone_types=None, *, chapter_texts=None): """立初卡:payload 全字段以旧值=NULL 入审计(G7,错认拆回可还原初始态)。 milestone_types(洞②):含「演变历程」字段的型集合(据库内合同动态判定)——该型立卡时若抽取结果 无登场里程碑,机械兜底补一条登场(保成长型实体登场即有生命周期起点);None/空集则不触发兜底。 @@ -641,7 +792,13 @@ def new_card(conn, work_id, win_no, ent, milestone_types=None): if k in MILESTONE_FIELDS: # 里程碑字段(演变历程):初卡即走对象合并(登场/首个进化台阶),去重排序; # 初卡尚无 draft_id 无法留审计,垃圾条目直接过滤(与下方字符串路径初卡同策略)。 - merged, _ = _merge_milestones([], v, win_no) + merged, _ = _merge_milestones( + [], + v, + win_no, + chapter_texts=chapter_texts, + require_evidence=True, + ) fields0[k] = merged continue if not isinstance(v, list): @@ -1072,12 +1229,21 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on): try: # ── 读1(短连接):窗正文 + 判重底册,读完即关(三段式红线:连接不跨 LLM/嵌入调用存活)── with psycopg.connect(DSN) as conn: - text = load_window_text(conn, work_id, a, b) + text, chapter_texts = load_window_material(conn, work_id, a, b) name_map, presence = load_known(conn, work_id) onstage = prescan(name_map, text) # ⓪ 机械预扫纯内存,无连接 # ── 算1(无连接):① 实体观察 M3 调用 + 输出清洗,全程不持连接 ── obs, usage = call(observe_prompt(contracts, title, a, b, text, onstage), ("新名字", "已知实体新信息", "纯出场")) + repair_missing_milestone_evidence( + obs, + title=title, + a=a, + b=b, + text=text, + chapter_texts=chapter_texts, + call=call, + ) # 模型输出防御(深空窗5实测:列表元素偶为裸字符串,.get 直接炸)——统一只留 dict 元素 for k in ("新名字", "已知实体新信息", "纯出场"): obs[k] = [x for x in (obs.get(k) or []) if isinstance(x, dict)] @@ -1148,7 +1314,14 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on): (work_id, canon, nm, win_no, TENANT)) continue if verdict == "chain": # 前身后继:仍立卡,串链关系记候选审计 - did = new_card(conn, work_id, win_no, ent, milestone_types) + did = new_card( + conn, + work_id, + win_no, + ent, + milestone_types, + chapter_texts=chapter_texts, + ) new_ids.append(did) name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", "")) # 只记候选提示、不自动写「前身/后继」字段——避免误串,链接由人工/后续确认落字段 @@ -1161,7 +1334,14 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on): json.dumps({"对方卡号": did2, "对方名称": nm2}, ensure_ascii=False), TENANT)) continue - did = new_card(conn, work_id, win_no, ent, milestone_types) + did = new_card( + conn, + work_id, + win_no, + ent, + milestone_types, + chapter_texts=chapter_texts, + ) new_ids.append(did) name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", "")) else: # 单章龙套 → 留档(G4) @@ -1200,6 +1380,15 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on): raise print(f"[宽容] 窗{win_no} 更新批缺键按空批放行: {str(e)[:80]}", file=sys.stderr) upd = {"更新": []} + repair_missing_milestone_evidence( + upd, + title=title, + a=a, + b=b, + text=text, + chapter_texts=chapter_texts, + call=call, + ) valid = {d for d, _, _ in cards} # 每卡按其型的合同 key 集校验(+一句话摘要),越合同 key 裁剪留审计 did2keys = {d: {f["key"] for f in contracts.get(p.get("type"), {}).get("字段", [])} @@ -1208,7 +1397,8 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on): if u.get("draft_id") in valid and (u.get("变更字段") or u.get("别名新增")): merge_card(conn, u["draft_id"], win_no, u.get("变更字段"), u.get("别名新增"), - valid_keys=did2keys.get(u["draft_id"])) + valid_keys=did2keys.get(u["draft_id"]), + chapter_texts=chapter_texts) # ⑤ 关系增量(核心角色=本窗有更新的 character + 在场 character,≤8) char_cards = [] seen = set() diff --git a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py index 9d0ddfb..20dd941 100644 --- a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py +++ b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py @@ -115,6 +115,57 @@ def test_clean_milestone_guard(): check("clean-截断前先推全文周期", m["周期"] == "高光") check("clean-抽后仍截至80", len(m["台阶"]) == 80) + # 新抽取里程碑必须用短原文证据把章号绑定到对应正文;证据只用于校验,不写入卡体。 + chapter_texts = { + 488: "安若雪率领亲卫继续突进,环星防御火力全面展开。", + 489: "安若雪与加特朗激战,同步率飙至100%,击碎能量屏障。", + } + verified = pu._clean_milestone( + { + "章": 489, + "台阶": "同步率飙至100%击碎加特朗屏障", + "周期": "高光", + "证据": "同步率飙至100%,击碎能量屏障", + }, + 84, + chapter_texts=chapter_texts, + require_evidence=True, + ) + check( + "clean-证据绑定正确章", + verified is not None and verified["章"] == 489 and "证据" not in verified, + ) + wrong_chapter = pu._clean_milestone( + { + "章": 488, + "台阶": "同步率飙至100%击碎加特朗屏障", + "周期": "高光", + "证据": "同步率飙至100%,击碎能量屏障", + }, + 84, + chapter_texts=chapter_texts, + require_evidence=True, + ) + check("clean-错章证据拒收", wrong_chapter is None) + missing_evidence = pu._clean_milestone( + {"章": 489, "台阶": "同步率飙至100%", "周期": "高光"}, + 84, + chapter_texts=chapter_texts, + require_evidence=True, + ) + check("clean-缺证据拒收", missing_evidence is None) + short_evidence = {"章": 488, "台阶": "继续突进", "周期": "成长", "证据": "安若雪率领亲卫"} + check( + "clean-不足8字证据拒收", + not pu._milestone_evidence_matches(short_evidence, chapter_texts), + ) + long_quote = "证" * 31 + long_evidence = {"章": 489, "台阶": "长证据", "周期": "成长", "证据": long_quote} + check( + "clean-超过30字证据拒收", + not pu._milestone_evidence_matches(long_evidence, {489: f"前缀{long_quote}后缀"}), + ) + # ── ④ 归并材料 _merge_material(洞②:演变历程完整不截、其余字段截断)── def test_merge_material(): @@ -177,6 +228,92 @@ def test_int_chaps(): check("intchaps-空输入", pu._int_chaps([]) == set() and pu._int_chaps(None) == set()) +def test_load_known_projection(): + """判重底册只投影索引字段,禁止跨网搬运每张卡的完整 payload。""" + + class Result: + def __init__(self, rows): + self.rows = rows + + def fetchall(self): + return self.rows + + class Conn: + def __init__(self): + self.queries = [] + + def execute(self, query, params): + self.queries.append(query) + if "muse_knowledge_draft" in query: + return Result([(1, "character", "张三", "主角", ["阿三"])]) + if "example_upgrade_alias" in query: + return Result([("张三", "老张")]) + return Result([("character", "李四", 7)]) + + conn = Conn() + name_map, presence = pu.load_known(conn, 8) + check("known-正名投影", name_map["张三"] == (1, "character", "主角")) + check("known-payload别名投影", name_map["阿三"] == name_map["张三"]) + check("known-别名表投影", name_map["老张"] == name_map["张三"]) + check("known-presence保留", presence[("character", "李四")] == {7}) + first_query = conn.queries[0] + check( + "known-禁搬完整payload", + "SELECT id, draft_payload FROM" not in first_query + and "draft_payload->>'名称'" in first_query, + ) + + +def test_repair_milestone_evidence(): + """只修本窗缺证据里程碑;未来重抄项不能借修复调用混回卡体。""" + + output = { + "更新": [ + { + "draft_id": 1, + "变更字段": { + "演变历程": [ + {"章": 489, "台阶": "同步率飙至100%", "周期": "高光"}, + {"章": 493, "台阶": "未来治疗事件", "周期": "高光"}, + ] + }, + } + ] + } + chapter_texts = {488: "防线开启。", 489: "安若雪同步率飙至100%,击碎能量屏障。"} + seen = {} + + def fake_call(prompt, required_keys): + seen["prompt"] = prompt + seen["required_keys"] = required_keys + return ( + { + "证据修复": [ + { + "ref": "$.更新[0].变更字段.演变历程[0]", + "章": 489, + "证据": "同步率飙至100%,击碎能量屏障", + } + ] + }, + {}, + ) + + repaired = pu.repair_missing_milestone_evidence( + output, + title="深空之影", + a=488, + b=489, + text="## 第488章\n防线开启。\n## 第489章\n安若雪同步率飙至100%,击碎能量屏障。", + chapter_texts=chapter_texts, + call=fake_call, + ) + milestones = output["更新"][0]["变更字段"]["演变历程"] + check("repair-本窗证据补齐", repaired == 1 and "证据" in milestones[0]) + check("repair-未来重抄不送修复", "证据" not in milestones[1] and "未来治疗事件" not in seen["prompt"]) + check("repair-固定输出键", seen["required_keys"] == ("证据修复",)) + + # ── ⑥ 三个提示词含关键纪律语句(洞②登场必须 / 洞③≤40字 + 体系级 / 洞②归并去重)── def test_prompts_disciplines(): c = _fake_contracts() @@ -184,12 +321,14 @@ def test_prompts_disciplines(): check("observe-登场必须硬约束", "必须给「演变历程」的首条登场里程碑" in obs) check("observe-台阶≤40字", "≤40 字" in obs) check("observe-体系级纪律", "体系级纪律" in obs) + check("observe-里程碑要求原文证据", '"证据"' in obs and "正文原样连续短句" in obs) upd = pu.update_prompt(c, "测试书", 1, 12, "正文占位", [(10, {"type": "power_system", "名称": "生物机甲"}, ["观察点"])]) check("update-台阶≤40字", "≤40 字" in upd) check("update-体系级纪律", "体系级纪律" in upd) check("update-登场归并去重", "若本卡演变历程已有登场里程碑则不再重复追加" in upd) + check("update-里程碑要求原文证据", '"证据"' in upd and "正文原样连续短句" in upd) rel = pu.relation_prompt(c, "测试书", 1, 12, "正文占位", [(10, {"名称": "张三"}), (11, {"名称": "李四"})], []) @@ -207,6 +346,7 @@ def test_prompts_disciplines(): if __name__ == "__main__": for fn in (test_classify_new_name, test_debut_milestone, test_clean_milestone_guard, test_merge_material, test_build_embed_text_type_fix, test_int_chaps, + test_load_known_projection, test_repair_milestone_evidence, test_prompts_disciplines): fn() print(f"\n全部离线自测通过:{_passed} 项(未连库、未发任何网络/嵌入/LLM 调用)") diff --git a/.claude/skills/replay-eval/SKILL.md b/.claude/skills/replay-eval/SKILL.md index 37d9b72..f5c4d39 100644 --- a/.claude/skills/replay-eval/SKILL.md +++ b/.claude/skills/replay-eval/SKILL.md @@ -55,7 +55,7 @@ disable-model-invocation: true - planner、detector、judge 子进程统一受 `--timeout-seconds` 限制,默认 300 秒;任一超时分别落盘 `planner_timeout`、`detector_timeout`、`judge_timeout`,不得继续进入后续阶段或标记 `completed`。 - `scripts/write_report.py`:从 `run_result.json` 生成独立严格 schema 的安全摘要,只接受受限标识符、枚举、数字、短安全摘要和 SHA-256;不会读取候选正文,也不会把候选路径以外的原始响应写入报告。 -真实作品运行前必须先从权威来源取得不可变授权快照。当前 work=8 已有 `research_only` 且 `allowedPurpose=["offline_evaluation"]` 的有效记录;真实 489 dry-run 因安若雪卡含错标目标事件被 `invalid_snapshot` 阻断,尚未进入 planner。其他作品缺记录时继续保持 `blocked_authorization`,不得伪造为 `licensed`。 +真实作品运行前必须先从权威来源取得不可变授权快照。当前 work=8 已有 `research_only` 且 `allowedPurpose=["offline_evaluation"]` 的有效记录;升格入口增加里程碑章号与所标章节原文短引的机械绑定并重抽第 84 窗后,真实 489 dry-run 已达到 `ready`、内容泄露发现为 0。首次模型执行因外部 Opus 网关 `503 No available accounts` 在第一臂 planner 前失败,未产生候选且费用为 0;通道恢复后必须完整重跑三臂。其他作品缺记录时继续保持 `blocked_authorization`,不得伪造为 `licensed`。 ## 正文 A/B/C 回放 diff --git a/docs/2026-07-16-升格卡改造设计.md b/docs/2026-07-16-升格卡改造设计.md index 7ace05f..bdc902e 100644 --- a/docs/2026-07-16-升格卡改造设计.md +++ b/docs/2026-07-16-升格卡改造设计.md @@ -1,6 +1,6 @@ # 升格卡改造设计(P0 补骨架记里程碑 · P1 串链语义判重) -- 版本:v1(2026-07-16 设计稿,供创始人评审,未实现、未动数据、未提交) +- 版本:v2(2026-07-20 已实现;新增里程碑章号原文证据绑定) - 目标读者:创始人 + 后续实现的子代理 - 边界:只改**参考书作品面升格卡**(`source_type='upgrade_book'`)的字段骨架、抽取提示词、判重方式、上下文裁剪标注;不碰范式卡、不碰主仓。概念定义对齐 `../design-docs/`(专题-06、架构-02)与源方案 `docs/2026-07-14-参考书作品面数据入库方案.md`,本稿是对该方案 §五「实体卡生命周期」的一次修订,不自立门户。 - 一句话结论:给机甲/武器/力量体系这些型补上一条"从登场到结局"的**演变历程**明细,用**真实章号**当索引(不用会变的运行时窗号),并把这条上万字的明细**只给一致性检查看、不给续写看**(续写只看当前态和一句话梗概);再用**语义判重**把改了名、换了型的同一条成长线并回一起、串成前后链。 @@ -18,7 +18,7 @@ - **第一步(先补骨架、记里程碑)**:给机甲/武器/力量体系这些型补上"演变历程"字段,把每一次进化台阶记成一条条里程碑,不再被覆写抹平、不再塞错字段。 - **第二步(再串链、上语义判重)**:加"前身/后继"把散落的碎卡串成一条进化链;判重从"只比名字字符串"升级到"比语义",治改名和跨型漏并。 -两个硬要求贯穿全程:**里程碑用真实章号索引(不用窗号)**;**卡片分两层给——续写只看当前态,一致性检查才看全历史**。 +三个硬要求贯穿全程:**里程碑用真实章号索引(不用窗号)**;**章号必须由所标章节原文短引机械证明**;**卡片分两层给——续写只看当前态,一致性检查才看全历史**。 --- @@ -98,7 +98,7 @@ flowchart TB 提示词纪律要加两条硬约束: -1. 每条里程碑**必须带真实章号**(正文里原样出现的"第 X 章"),**禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代**——因为卡会脱离运行环境被单独阅读。 +1. 每条里程碑**必须带真实章号**,并临时附一段来自所标章节正文的连续短引;系统按章读取原文做逐字机械核验,错章、缺证据或改写证据均拒收入库。**禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代**——因为卡会脱离运行环境被单独阅读。证据只用于写入前校验,校验后删除,不进入正式卡体。 2. 生命周期标记从固定枚举里选:**登场 / 成长 / 高光 / 退场 / 结局**。这一条直接治"铁头机甲摘要跳到已退役、中间态全丢"——有了登场和退场节点,一台机甲什么时候来的、什么时候谢幕,一目了然。 ### 4.3 合并逻辑要跟着改 @@ -122,19 +122,21 @@ flowchart TB ### 5.2 里程碑对象结构(每条演变历程条目长这样) -推荐用**极简三字段结构化对象**,既能按章号排序/定位,又不会因字段太多让模型抽崩: +正式卡体使用**极简三字段结构化对象**,既能按章号排序/定位,又不会因字段太多让模型抽崩。抽取模型输出时临时增加第四个 `证据` 字段,写入前机械核验并删除: ```json { "章": 420, "台阶": "V代编队首战:暗星帝国出动十台生物机甲配合亡灵号,扭转多瑙星海战场局势", - "周期": "高光" + "周期": "高光", + "证据": "所标章节正文中的连续短句" } ``` - **章**:绝对章号,用于索引与排序。单章事件填整数(`420`);跨多章的连续事件填区间字符串(`"420-423"`)。**这是"用绝对章号索引"的落点**。 - **台阶**:一句话讲清"进化到什么 + 靠什么事件"。刻意把"结果 + 经过"合成一句,不再往下拆细字段,避免模型输出格式崩。 - **周期**:生命周期枚举,取值 `登场 / 成长 / 高光 / 退场 / 结局`。 +- **证据**:仅存在于抽取传输层,必须逐字出现在「章」所指正文中;机械核验后删除,正式卡仍保持三字段。 这个结构是有真实依据的:库里关系卡「苏铭×林初雨」早期就自发用过 `{状态, 章区间: "Ch.21", 转折事件}` 这种带章号的结构化格式,证明模型抽得出、章号索引可行。三字段是"结构化可查询"和"模型稳定"之间的最优平衡点。 @@ -211,7 +213,7 @@ flowchart TB | `meta/schemas/faction.yaml` `location.yaml` `event.yaml` | 同上(建议同批补,避免二次改;见拍板点) | schema | | `meta/schemas/character.yaml` | 语义对齐:明确「成长弧线」=未来计划,已发生台阶归「演变历程」(是否给 character 也加演变历程见拍板点) | schema | | `meta/schemas/character_relation.yaml` | 「演变轨迹」说明里"章区间"沿用真实章号(本就如此),确认 aiContext 保持 `true` | schema | -| `.claude/skills/parse-book/scripts/parse_upgrade.py` | ①`APPEND_FIELDS` 加「演变历程」②observe/update 提示词改为记台阶+生命周期+真实章号、禁窗号相对指代、当前态字段保持干净 ③合并/立卡/撤销逻辑适配里程碑对象(去重按内容、排序按章号)④判重步接语义近邻+M3 终判(P1) | prompt + 逻辑 | +| `.claude/skills/parse-book/scripts/parse_upgrade.py` | ①`APPEND_FIELDS` 加「演变历程」②observe/update 提示词改为记台阶+生命周期+真实章号+原文证据、禁窗号相对指代、当前态字段保持干净 ③合并/立卡/撤销逻辑适配里程碑对象(证据绑定、去重按内容、排序按章号)④判重步接语义近邻+M3 终判(P1) | prompt + 逻辑 | | `.claude/skills/db/scripts/seed_schemas.py` | 不改脚本;schema YAML 改完后**需重跑一次**把新字段和 aiContext 灌进库(本稿不跑,实施时跑) | 种子 | | `.claude/skills/read-context/SKILL.md` | Layer 2 知识卡裁剪说明里,点名"演变历程完整层仅一致性检查可见、续写不给"(机制已支持,补一句说明) | skill 文档 | | `.claude/skills/detect/SKILL.md` | 检查项示例表补一行"演变历程 → 演变连续性/跳级穿帮"(实际自动生成,文档补例) | skill 文档 | @@ -245,7 +247,7 @@ flowchart TB ### 风险 1. **条目从字符串变对象,合并逻辑改动面不小**。`parse_upgrade.py` 现有一大套针对字符串条目的守卫(剥前缀、剥尾残、拦垃圾、拆粘连、窗序归位),全要跟着改造成处理里程碑对象;存量卡还是字符串,迁移要兼容。→ 缓解:分两步落,先补字段和提示词(新卡受益),再做存量迁移;对象结构压到三字段降低崩坏面。 -2. **模型抽结构化对象比抽字符串更容易格式崩**。→ 缓解:三字段极简;保留降级——抽不出完整对象就退化成"章号 + 一句话"最小对象,别整条丢。 +2. **模型可能漏证据或给出错章证据**。→ 缓解:卡体仍保持三字段;模型输出临时附证据,缺失时只对当前窗候选发一次有界证据修复调用;最终仍无法由所标章节原文证明的条目拒收并留审计。完整性不能优先于章号真实性。 3. **语义判重增加调用量和跨型误并风险**。→ 缓解:0.60 阈值(实测校准) + 同型才自动并 + 跨型仅提示;判重近邻可像 v6 说的那样批量做、不逐条烧。 4. **存量迁移精度损失**:无内嵌章号的条目只能靠窗映射到 12 章宽区间。→ 接受为存量固有局限,新卡不受影响。 5. **改 schema 后必须重跑种子**,否则库里字段合同和 YAML 不一致,抽取会按旧合同把新字段裁掉。→ 实施清单里钉死"改 YAML 后重跑 `seed_schemas.py`"这一步。 @@ -257,13 +259,14 @@ flowchart TB - **一致性检查自动加项**:对一章跑 `detect`,确认检查清单里自动多了"演变连续性"这一项。 - **语义判重召回**:造一个改名样例(影杀者 / IV 代纯机械机甲·影杀者),确认语义判重能召回并由 M3 判为同一实体或前身后继。 - **章号无窗号残留**:迁移后全库扫一遍,确认演变类字段里不再有 [窗N] 前缀。 +- **章号证据真绑定**:用跨章窗口重抽验证,错标章节的里程碑必须拒收;正确章节原文短引通过后,正式卡只保留章 / 台阶 / 周期,不残留证据字段。 --- ## 十、需要创始人拍板的点 > **✅ 已拍板(2026-07-17,创始人 4 项 + 主代理定 #4)——实现以此为准,下方原始理由留档:** -> 1. 里程碑格式 = **结构化对象(章 / 台阶 / 周期)**,配降级(抽不出完整对象退成"章号 + 一句话",不整条丢)。 +> 1. 里程碑卡体格式 = **结构化对象(章 / 台阶 / 周期)**;抽取传输临时增加原文证据,机械核验后删除。无法证明所标章号的条目拒收并留审计,不做猜测性降级。 > 2. 补齐范围 = **五型全补**:item / power_system / faction / location / event。 > 3. character = **一起改**:给人物也加「演变历程」、「成长弧线」回归"未来计划"本义,连带迁移 200+ 张存量人物卡。 > 4. 演变概括 = **独立字段**(主代理定:摘要管当前态、概括管轨迹,职责清晰)。 diff --git a/docs/2026-07-19-回放评测-细纲首跑设计与计划.md b/docs/2026-07-19-回放评测-细纲首跑设计与计划.md index d248ddc..e91aab9 100644 --- a/docs/2026-07-19-回放评测-细纲首跑设计与计划.md +++ b/docs/2026-07-19-回放评测-细纲首跑设计与计划.md @@ -14,11 +14,14 @@ - 已验证:replay-eval 127 个离线测试全部通过;fake runner 已跑通三臂 planner、真正不识别臂卡内容的逐候选 detector、双 judge 反序盲评、子进程超时失败关闭、稳定性门和去盲矩阵。合成 fixture 不代表参考作品评测结果。 - 已新增并验证:`audit_leakage.py` 对公共快照和三臂卡注入区执行内容级事实审计;`load_reference_work.py` 通过 PostgreSQL 只读事务组装仓库外临时配置,候选卡标记为 `eval_draft`,不进入生产检索。 - 授权表已应用,work=8 已有一条有效的 `research_only + offline_evaluation` 不可变快照;原文件、document SHA-256 和 `raw-file-v1` 来源版本三方一致,重验时间为 2026-08-18。 -- 已执行真实 work=8 / as_of=488 / target=489 dry-run:正确卡选择为安若雪与圣蒂曼行星,placebo 为同型近体量的伊蕾莉雅与鹦鹉螺号;未调用模型。 -- 首次真实 dry-run 暴露内容审计假阴性:安若雪卡把第 489 章“加特朗 / 同步率 100%”事件错标为第 488 章,旧审计只做完整字符串包含而错误放行。现已增加原子事件子句、多强锚点和目标子句覆盖率门,真实样本被正确阻断为 `invalid_snapshot`,不生成 manifest、不进入 planner。 -- 当前状态:**授权与真实装配已通,目标样本因上游卡章号错标被内容门阻断,模型实跑未开始**。必须先修正或隔离泄露来源,再执行三臂模型回放。 +- 已执行真实 work=8 / as_of=488 / target=489 dry-run:正确卡选择为安若雪与圣蒂曼行星,placebo 为同型近体量的伊蕾莉雅与鹦鹉螺号。重抽后活动地点卡由旧 ID `12383` 变为 `21881`,评测卡选择按活动卡重登记;卡 ID 漂移只影响运行配置,不改变冻结点、卡型或评测变量。 +- 首次真实 dry-run 暴露内容审计假阴性:安若雪卡把第 489 章“加特朗 / 同步率 100%”事件错标为第 488 章,旧审计只做完整字符串包含而错误放行。现已增加原子事件子句、多强锚点和目标子句覆盖率门;同时在升格抽取入口增加“章号 + 所标章节原文短引”机械绑定,缺证据、错章证据和改写证据均拒收入库,证据字段校验后丢弃。 +- 已重抽 work=8 第 84 窗(487–489 章):20 次调用、21 张卡更新;机械门拒绝 19 条无法由所标章节正文支撑的里程碑,其中包含错标为第 488 章的加特朗 / 100% 同步率事件。正式卡未写入临时证据字段,目标泄露扫描为 0。 +- 修复后重新装配并运行真实 dry-run:授权有效,三臂公共输入哈希一致,仅卡注入分区不同,`leakageAudit.findingCount=0`,状态为 `ready`。 +- 已尝试启动三臂模型实跑,但第一臂 planner 前被 Claude 推理网关 `503 No available accounts` 阻断;随后极小 Opus 连通性探测再次得到相同 503。两次均为 0 输入 token、0 输出 token、0 费用,未产生候选细纲,后两臂、detector 和双 judge 均未执行。 +- 当前状态:**授权、真实装配、上游章号证据绑定和真实 dry-run 已通;模型实跑仅被外部 Opus 通道阻塞**。通道恢复后必须从完整三臂重新执行,不能复用这次未完成的第一臂,也不能换模型污染变量控制。 - `.claude/skills/llm/scripts/test_quota.py` 已统一到当前 `$24/6000` 契约:预算场景使用 `$24.5`,调用上限场景使用 `6000`,并增加策略常量断言。 -- 当前允许的结论是“授权、真实数据装配、冻结、变量控制、内容级泄露阻断、候选结构门、detector/judge 双评编排和安全摘要机制已通”;不能说细纲智能体或知识卡已通过。489 样本需先清除泄露来源,430/550 样本仍待执行。 +- 当前允许的结论是“授权、真实数据装配、冻结、变量控制、上游里程碑证据绑定、内容级泄露阻断、候选结构门、detector/judge 双评编排和安全摘要机制已通”;不能说细纲智能体或知识卡已通过。489 样本等待 Opus 通道恢复后执行,430/550 样本仍待执行。 ---