From b5b269f97b1137cb6a70769662fcdda829cff277 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 15 Jul 2026 10:42:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=86=E6=9E=B6:=20=E6=8A=BD=E6=A3=804?= =?UTF-8?q?=E9=AB=982=E4=B8=AD=E4=BF=AE=E5=A4=8D(=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E5=8E=BB=E9=87=8D/=E5=87=BA=E5=9C=BA=E7=AB=A0=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E6=9B=B4=E6=96=B0=E5=8D=A1/=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E8=90=BD=E7=82=B9=E7=BB=9F=E4=B8=80/=E8=A6=86=E5=86=99?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E6=8B=A6=E6=88=AA/=E5=88=AB=E5=90=8D?= =?UTF-8?q?=E5=87=86=E5=85=A5/=E7=AB=8B=E5=8D=A1=E5=89=A5=E6=8B=AC?= =?UTF-8?q?=E5=8F=B7)+=E5=AD=98=E9=87=8F=E5=9B=9E=E6=B4=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parse-book/scripts/parse_upgrade.py | 125 ++++++++++++++---- 1 file changed, 99 insertions(+), 26 deletions(-) diff --git a/.claude/skills/parse-book/scripts/parse_upgrade.py b/.claude/skills/parse-book/scripts/parse_upgrade.py index 190c008..1329bcb 100644 --- a/.claude/skills/parse-book/scripts/parse_upgrade.py +++ b/.claude/skills/parse-book/scripts/parse_upgrade.py @@ -168,7 +168,7 @@ def observe_prompt(contracts, title, a, b, text, onstage): 1) 新名字:正文出现、但「在场已知实体」清单里没有的实体(六型:{"/".join(ENTITY_TYPES)})。每个给:型、名称、别名、一句话摘要、按该型合同能填的字段(有正文证据才填)、出场章号。若你怀疑它其实是清单中某已知实体的别名/改名/化名,填「疑似别名指向」。 2) 已知实体新信息:清单中实体在本窗的实质新信息(境界变化/性格显露/重大经历/立场转变),一条 ≤60 字观察点。没有实质新信息的不要报。 3) 纯出场:清单中实体本窗出现但无实质新信息的,只报名称+出场章。 -纪律:一次性龙套(单章无名或仅路过)不报进新名字;实体判据与字段以合同为准,无证据不填;不脑补。 +纪律:一次性龙套(单章无名或仅路过)不报进新名字;实体判据与字段以合同为准,无证据不填;不脑补。数值(战力/指数/排名等)必须正文原样出现才可写,禁止推算或编造。组织改组/合并产生的新组织是**新实体**(走新名字),不是旧组织的别名。「疑似别名指向」只在确为同一实体改名/化名时填。 【六型字段合同】 {render_entity_contracts(contracts, ENTITY_TYPES)} @@ -192,10 +192,10 @@ def update_prompt(contracts, title, a, b, text, cards_with_obs): for d, p, o in cards_with_obs], ensure_ascii=False, indent=1) return f"""【功能指令(parse-book 作品面升格·卡增量更新)】 下列每张卡给出「当前卡全文」与「本窗观察点」。对照本窗正文,**只输出需要变更的字段**: -- 覆写类字段(性格底色/说话方式/当前状态等标量):输出更准确的新值(整字段替换); -- 追加类字段(成长弧线/演变轨迹等数组):只输出本窗新增条目(不要重抄旧条目); +- 覆写类字段(性格底色/说话方式/当前状态等标量):**必须输出该字段完整的新全量值**——旧值里仍然成立的信息要保留进新值,禁止只写"新增…"式增量(那会把旧信息抹掉); +- 追加类字段(成长弧线/演变轨迹等数组):只输出本窗新增条目(不要重抄旧条目,不要自己加 [窗N] 前缀,系统会加); - 没有变化的字段不要输出;整卡无实质变化则不输出该卡。 -纪律:以正文为证据,不脑补;字段 key 必须来自该型合同;每卡别名有新发现可在「别名新增」里给——只收**该实体自己**的新别名(他人对它的称呼算,它对别的实体的称呼不算:窗2实测「魔鬼金」是主角喊宠物机的诨名,错报成了主角别名)。 +纪律:以正文为证据,不脑补;数值必须正文原样出现,禁止推算;字段 key 必须来自该型合同;每卡别名有新发现可在「别名新增」里给——只收**该实体自己**的新别名(他人对它的称呼算,它对别的实体的称呼不算),且必须是可在正文原样出现的纯名字(禁带括号注释与说明文字)。 【相关型字段合同】 {render_entity_contracts(contracts, sorted({p.get("type") for _, p, _ in cards_with_obs} & set(ENTITY_TYPES)))} @@ -241,8 +241,31 @@ def relation_prompt(contracts, title, a, b, text, char_cards, existing_rels): # ── 合并与审计(三类字段演进 + 撤销依据)── +WIN_PREFIX_RE = None # 延迟编译(模块顶部 import re 已有) + + +def _strip_prefix(s): + """剥条目行首的 [窗N]/[本窗] 前缀(可能多重堆叠),返回纯内容。""" + import re as _re + global WIN_PREFIX_RE + if WIN_PREFIX_RE is None: + WIN_PREFIX_RE = _re.compile(r"^(?:\[窗\d+\]\s*|\[本窗\]\s*)+") + return WIN_PREFIX_RE.sub("", str(s)).strip() + + +def _clean_alias(al): + """别名机械准入(抽检#6):拒收含括号注释/超长的备忘录式别名——预扫精确匹配永不命中=死数据。""" + al = (al or "").strip() + if not al or len(al) > 12: + return None + if any(c in al for c in "()()。,,:"): + return None + return al + + def merge_card(conn, draft_id, win_no, changes, alias_new): - """按 5.1 三类规则合并变更字段:数组/白名单=追加(条目带窗号),标量=覆写留审计。""" + """按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号), + 标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。""" payload = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s", (draft_id,)).fetchone()[0] fields = payload.setdefault("字段", {}) @@ -252,10 +275,20 @@ def merge_card(conn, draft_id, win_no, changes, alias_new): [a for a in (changes.pop("别名新增", None) or []) if isinstance(a, str)] for k, v in changes.items(): is_append = k in APPEND_FIELDS or isinstance(v, list) or isinstance(fields.get(k), list) + # 覆写值以增量口吻开头=模型把增量当全量(抽检#4:新值会抹掉旧基线),转追加不覆写 + if not is_append and isinstance(v, str) and \ + any(v.lstrip().startswith(w) for w in ("新增", "本窗", "另外", "此外")): + is_append = True if is_append: items = v if isinstance(v, list) else [v] old = fields.get(k) if isinstance(fields.get(k), list) else ([fields[k]] if fields.get(k) else []) - fields[k] = old + [f"[窗{win_no}] {it}" for it in items if it] + seen = {_strip_prefix(x) for x in old} # 同文去重(抽检#1 重复病) + for it in items: + core = _strip_prefix(it) # 剥模型自带前缀(抽检#1 堆叠病) + if core and core not in seen: + old.append(f"[窗{win_no}] {core}") + seen.add(core) + fields[k] = old else: conn.execute( """INSERT INTO example_upgrade_audit @@ -265,17 +298,18 @@ def merge_card(conn, draft_id, win_no, changes, alias_new): if fields.get(k) is not None else None, json.dumps(v, ensure_ascii=False), TENANT)) fields[k] = v - for al in alias_new or []: - if al and al.strip() and al.strip() != payload.get("名称"): + for al0 in alias_new or []: + al = _clean_alias(al0) + if al and al != payload.get("名称"): payload.setdefault("别名", []) - if al.strip() not in payload["别名"]: - payload["别名"].append(al.strip()) + if al not in payload["别名"]: + payload["别名"].append(al) conn.execute( """INSERT INTO example_upgrade_alias (work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id) VALUES (%s,%s,%s,%s,'ai',%s) ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""", - (payload.get("_work_id") or 0, payload.get("名称"), al.strip(), win_no, TENANT)) + (payload.get("_work_id") or 0, payload.get("名称"), al, win_no, TENANT)) conn.execute( """UPDATE muse_knowledge_draft SET draft_payload=%s, revision=revision+1, updater='upgrade' WHERE id=%s""", @@ -289,9 +323,21 @@ def merge_card(conn, draft_id, win_no, changes, alias_new): def new_card(conn, work_id, win_no, ent): - """立初卡:payload 全字段以旧值=NULL 入审计(G7,错认拆回可还原初始态)。""" - payload = {"type": ent["型"], "名称": ent["名称"].strip(), - "别名": [a.strip() for a in ent.get("别名", []) if a and a.strip()], + """立初卡:payload 全字段以旧值=NULL 入审计(G7,错认拆回可还原初始态)。 + + 名称剥括号注(抽检#7 根因):「白色游魂(无名侦察兵)」这类名称使预扫精确匹配失明 + ——主名之外的括号内容若像名字则转别名,否则丢弃。""" + import re as _re + raw = ent["名称"].strip() + m = _re.match(r"^(.+?)[((](.+?)[))]\s*$", raw) + extra_alias = [] + if m: + raw = m.group(1).strip() + note = _clean_alias(m.group(2)) + if note: + extra_alias.append(note) + payload = {"type": ent["型"], "名称": raw, + "别名": [x for x in ([_clean_alias(a) for a in ent.get("别名", [])] + extra_alias) if x], "一句话摘要": ent.get("一句话摘要", ""), "字段": ent.get("字段", {}) or {}, "出场章": sorted(set(ent.get("出场章", []))), @@ -477,10 +523,14 @@ def run(work_id, max_windows, max_calls, model, redo_window): VALUES (%s,%s,%s,%s,%s,%s,%s)""", (work_id, win_no, ch, ent.get("型", ""), nm, ent.get("一句话摘要", ""), TENANT)) + pres_add = {} # did -> set(出场章)——被更新的卡也要记出场章(抽检#2:只走纯出场路径整窗丢章) for it in obs.get("已知实体新信息", []): nm = (it.get("名称") or "").strip() if nm in name_map and it.get("观察点"): - to_update.setdefault(name_map[nm][0], []).append(it["观察点"]) + did = name_map[nm][0] + to_update.setdefault(did, []).append(it["观察点"]) + pres_add.setdefault(did, set()).update( + c for c in (it.get("出场章") or []) if isinstance(c, int)) # ④ 卡更新(分批≤6) items = sorted(to_update.items()) for i in range(0, len(items), UPDATE_BATCH): @@ -529,10 +579,28 @@ def run(work_id, max_windows, max_calls, model, redo_window): if ja not in id2name or yi not in id2name or ja == yi: continue key = tuple(sorted((ja, yi))) - evo = f"[窗{win_no}] {r.get('本窗演变', '')}" + evo_core = _strip_prefix(r.get("本窗演变", "")) if key in exist: + # 落点统一(抽检#3):其他字段与演变轨迹全并进 rp["字段"], + # 顶层遗留的演变轨迹迁移进字段后删除,避免双份分裂 rid, rp = exist[key] - rp.setdefault("演变轨迹", []).append(evo) + f2 = rp.setdefault("字段", {}) + if "演变轨迹" in rp: # 存量顶层迁移 + legacy = rp.pop("演变轨迹") + base = f2.get("演变轨迹") or [] + f2["演变轨迹"] = (base if isinstance(base, list) else [base]) + \ + (legacy if isinstance(legacy, list) else [legacy]) + for k2, v2 in (r.get("其他字段") or {}).items(): + if k2 == "演变轨迹": + continue # 演变只走本窗演变通道 + f2[k2] = v2 # 覆写(当前状态等保最新) + if evo_core: + lst = f2.setdefault("演变轨迹", []) + if not isinstance(lst, list): + lst = [lst] + f2["演变轨迹"] = lst + if evo_core not in {_strip_prefix(x) for x in lst}: + lst.append(f"[窗{win_no}] {evo_core}") if r.get("关系类型"): rp["关系类型"] = r["关系类型"] conn.execute( @@ -540,12 +608,14 @@ def run(work_id, max_windows, max_calls, model, redo_window): revision=revision+1 WHERE id=%s""", (json.dumps(rp, ensure_ascii=False), rid)) else: + f2 = dict(r.get("其他字段") or {}) + f2["演变轨迹"] = [f"[窗{win_no}] {evo_core}"] if evo_core else [] rp = {"type": RELATION_TYPE, "名称": f"{id2name[ja]}×{id2name[yi]}", "甲方draft": ja, "乙方draft": yi, "甲方名称": id2name[ja], "乙方名称": id2name[yi], "关系类型": r.get("关系类型", ""), - "演变轨迹": [evo], "字段": r.get("其他字段", {}) or {}, + "字段": f2, "来源": f"升格@窗{win_no}", "状态": "草稿", "目标库": "本书作品库", "可见范围": "本书私有", "_work_id": work_id} @@ -556,17 +626,20 @@ def run(work_id, max_windows, max_calls, model, redo_window): VALUES (%s,'entity',%s,'pending',%s,%s,'upgrade','upgrade',%s)""", (work_id, json.dumps(rp, ensure_ascii=False), SOURCE_TYPE, work_id, TENANT)) - # ⑥ 机械收尾:纯出场留档 + 窗置 done + # ⑥ 机械收尾:出场章统一并入(纯出场 + 被更新卡,抽检#2)+ 窗置 done for it in obs.get("纯出场", []): nm = (it.get("名称") or "").strip() if nm in name_map: - did = name_map[nm][0] - p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s", - (did,)).fetchone()[0] - chs = sorted(set(p.get("出场章", [])) | set(it.get("出场章", []))) - p["出场章"] = chs - conn.execute("UPDATE muse_knowledge_draft SET draft_payload=%s WHERE id=%s", - (json.dumps(p, ensure_ascii=False), did)) + pres_add.setdefault(name_map[nm][0], set()).update( + c for c in (it.get("出场章") or []) if isinstance(c, int)) + for did, chs in pres_add.items(): + if not chs: + continue + p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s", + (did,)).fetchone()[0] + p["出场章"] = sorted(set(p.get("出场章", [])) | chs) + conn.execute("UPDATE muse_knowledge_draft SET draft_payload=%s WHERE id=%s", + (json.dumps(p, ensure_ascii=False), did)) conn.execute( """UPDATE example_upgrade_window SET status='done', error_message=NULL, updater='upgrade' WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",