feat(parse-book): 升格卡改造前向落地——实体成长「演变历程」骨架+真实章号里程碑+渐进披露+语义判重

schema(6型 item/power_system/faction/location/event/character):加「演变历程」(里程碑对象数组{章,台阶,周期},aiContext[detection,extraction]=续写不给防上万字过载、仅一致性检查与判重可见)+「演变概括」现状层+「前身/后继」串链;character「成长弧线」语义回归'未来计划'。已重跑 seed_schemas 灌字段合同(6型合同均含演变历程,亲验)。

parse_upgrade.py:里程碑对象合并去重按真实章号(不用运行时窗号)+撤销靠内部_win键(防同窗重跑重复追加)+当前态字段干净纪律(治升级线塞错字段病根)+observe/update提示词记进化台阶与登场→结局生命周期+语义判重(embed≥0.78召回+M3终判,同型自动并/跨型仅串链候选,--semantic-dedup默认关)。

migrate_upgrade_windows.py:存量[窗N]→真实章号迁移(全脚本只读零写库;人工精确化=无内嵌章号处再抽原文定章;全库563卡待迁/6616条待LLM/669内嵌自动精确)。read-context/detect SKILL.md 补渐进披露与演变连续性检查说明。待gate未执行:真迁移落库/开语义判重。设计+拍板见 docs/2026-07-16-升格卡改造设计.md。

创始人4项拍板:①里程碑结构化对象②五型全补③character一起改④存量人工精确化(+主代理定演变概括独立)。
This commit is contained in:
zizi 2026-07-17 06:13:18 +08:00
parent 9c87f0dee9
commit 178603568e
11 changed files with 978 additions and 8 deletions

View File

@ -19,6 +19,7 @@ disable-model-invocation: true
| power_system.代价限制 | 境界与能力是否越过体系代价 | | power_system.代价限制 | 境界与能力是否越过体系代价 |
| location.规则特例 | 地点规则是否被违反 | | location.规则特例 | 地点规则是否被违反 |
| item 能力边界 | 物品表现是否超出卡面 | | item 能力边界 | 物品表现是否超出卡面 |
| 演变历程(各实体型) | 演变连续性/跳级穿帮(上一章还4级、这一章突然7级;登场/退场节点缺失或时间线倒挂) |
| outline.伏笔动作 | 埋/推/收是否照细纲执行,有无擅自新开或提前回收 | | outline.伏笔动作 | 埋/推/收是否照细纲执行,有无擅自新开或提前回收 |
| work_core.谜底与真相/结局方向 | **防提前泄底**——这正是检测可见底牌的原因 | | work_core.谜底与真相/结局方向 | **防提前泄底**——这正是检测可见底牌的原因 |
| style 黑名单与画像 | Grep 逐词扫黑名单;宣告情绪/形容词堆叠/无三件套场景抽查 | | style 黑名单与画像 | Grep 逐词扫黑名单;宣告情绪/形容词堆叠/无三件套场景抽查 |

View File

@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""存量升格卡 [窗N] → 真实章号迁移(升格卡改造 §6.1)——**只 --dry-run,全程零写库**。
背景:老升格卡的演变类字段(character.成长弧线 / character_relation.演变轨迹)里,条目带
运行时窗号前缀 [窗N]/[本窗]。窗号是切窗规则的运行产物(会变、脱离环境不可读),必须换成
永不变的真实章号(第X章)。本脚本把这些条目:
- 实体型(character 等):对象化成里程碑 {章,台阶,周期} 汇入「演变历程」;成长弧线回归"未来计划"。
- 关系型(character_relation):保持「演变轨迹」字符串结构,只把 [窗N] 换真实章号前缀。
真实章号定位优先级(落实拍板#5「人工精确化」,不接受 12 章宽近似):
1) 内嵌章号——条目文字里模型已写"第420章/119章/Ch.21",正则直接抽,最准、零 LLM。
2) 前缀误标章号——[窗307-315] 里 307 远超该书窗号上限(work4=80/work8=116),是"第307章"
被误加了"窗"字,采信为章号、零 LLM。
3) 真窗号 [窗N](N≤上限)——读该窗源文、LLM(走 chat_governed 全局治理)定位到真实章。
4) [本窗]/无任何线索——定不了,打"待人工"标记,绝不用窗区间近似糊弄。
范围边界(诚实标注):本脚本只迁**演变类字段**(成长弧线/演变轨迹/演变历程/大事记/经历)。
item/power_system 等把升级线塞在当前态字段(流转计划/戏剧作用/跨体系换算…)的病根2,属于
"当前态字段污染",需 P0 提示词改造后**重抽**清理,非本机械迁移能正确归位——脚本对这类字段
只做「污染报告」提示、不动数据。
连接纪律(硬约束):短连接读→释放→LLM→(本脚本不写库)。绝不在 DB 事务里夹 LLM 调用。
本脚本无任何 UPDATE/INSERT muse_knowledge_draft——迁移落库由主代理 gate 后另行发起。
"""
import json
import pathlib
import re
import sys
import click
import psycopg
# 复用 parse_upgrade 的守卫/工具(剥前缀、剥尾残、垃圾拦截、内嵌章号、生命周期推断、章号排序键、
# 窗源文加载、SOURCE_TYPE/TENANT/DSN)——迁移与抽取同一套清洗口径,不另立标准。
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parents[1] / "llm" / "scripts"))
import parse_upgrade as pu # noqa: E402
from llm import chat_governed, extract_json # noqa: E402
DSN = pu.DSN # 带 keepalives(防 Tailscale 长空转掐断),与抽取同源
SOURCE_TYPE = pu.SOURCE_TYPE
TENANT = pu.TENANT
# 演变类字段白名单:只有这些字段的 [窗N] 条目属"演变台阶"、走章号迁移;其余字段的 [窗N]
# 是当前态字段被污染,不在迁移范围(见文件头「范围边界」)。
EVOLUTION_FIELDS = {"成长弧线", "演变轨迹", "演变历程", "大事记", "经历"}
RELATION_TYPE = pu.RELATION_TYPE
# 条目行首 [窗…] 前缀内的原始标记('窗2' / '窗307-315' / '本窗' / '窗本窗')
PREFIX_RE = re.compile(r"^\[([窗本][^\]]{0,12})\]")
def parse_prefix(entry):
"""提取条目行首 [窗…] 前缀内容;无前缀返回 None。"""
m = PREFIX_RE.match(str(entry).strip())
return m.group(1) if m else None
def resolve_chapter(entry, win_max):
"""给单条演变条目定真实章号。返回 (章号, 来源, 待LLM窗号)。
章号:整数 / 区间字符串"307-315" / None(待LLM 或 待人工时为 None)。
来源 ∈ 内嵌 / 前缀误标章号 / 待LLM / 待人工。"""
body = pu._strip_tail(pu._strip_prefix(entry))
inline = pu._extract_inline_chapter(body)
if inline is not None:
return inline, "内嵌", None # ① 最准:条目自带真实章号
pref = parse_prefix(entry)
if pref:
nums = re.findall(r"\d+", pref)
if nums:
lo = int(nums[0])
if lo > win_max: # ② 超窗号上限=章号被误标为窗号
ch = nums[0] if len(nums) == 1 else f"{nums[0]}-{nums[1]}"
return ch, "前缀误标章号", None
return None, "待LLM", lo # ③ 真窗号,需读源文 LLM 精确化
return None, "待人工", None # ④ [本窗]/无线索,定不了
def migrate_relation_entry(entry, ch, src):
"""关系型「演变轨迹」条目迁移:保持字符串,只把 [窗N] 换真实章号前缀(不对象化)。"""
body = pu._strip_tail(pu._strip_prefix(entry))
if parse_prefix(entry) is None:
return entry # 无 [窗N](已是真实章号或纯文本),原样不动
if src == "内嵌":
return body # body 自带章号,剥掉窗前缀即干净
if ch is not None:
return f"第{ch}章:{body}" # 误标章号/LLM 定位:补真实章号前缀
if src == "待LLM":
return f"[待LLM] {body}" # 真窗号未精确化:留标记,别当定不了
return f"[待人工] {body}" # 定不了([本窗]/无线索):显式标记,不糊弄
def migrate_entity_entry(entry, ch, src):
"""实体型演变条目对象化成里程碑 {章,台阶,周期};降级保底不丢条。
章号定不了(src=待人工)打「待人工」标记供人工精确化;待LLM(未精确化)不标(开 --llm 会定到)。"""
body = pu._strip_tail(pu._strip_prefix(entry))
m = {"章": ch, "台阶": body, "周期": pu._infer_lifecycle(body)}
if ch is None and src == "待人工":
m["待人工"] = True
return m
# ── 短连接读取(读完即释放,LLM/打印阶段不持连接)──
def load_window_maxima(conn):
"""各作品窗号上限:{work_id: 最大窗号}——判「前缀数字是否超上限=章号误标」。"""
rows = conn.execute(
"SELECT work_id, max(window_no) FROM example_upgrade_window WHERE deleted=FALSE GROUP BY work_id"
).fetchall()
return {w: int(mx) for w, mx in rows}
def load_win_ranges(conn, work_id, win_nos):
"""读指定窗的章区间:{窗号: (from_ch, to_ch)}——给 LLM 精确化缩小定位范围。"""
if not win_nos:
return {}
rows = conn.execute(
"""SELECT window_no, from_chapter, to_chapter FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND window_no = ANY(%s) AND deleted=FALSE""",
(TENANT, work_id, list(win_nos))).fetchall()
return {int(w): (a, b) for w, a, b in rows}
def load_target_cards(conn, work_id, ids, limit):
"""读待迁卡:含演变类字段且该字段带 [窗/本窗] 前缀条目的 upgrade_book 卡。
返回 [(id, work_id, payload)]。"""
sql = ["""SELECT id, work_id, draft_payload FROM muse_knowledge_draft
WHERE tenant_id=%s AND source_type=%s AND deleted=FALSE""", ]
args = [TENANT, SOURCE_TYPE]
if work_id is not None:
sql.append("AND work_id=%s"); args.append(work_id)
if ids:
sql.append("AND id = ANY(%s)"); args.append(ids)
sql.append("ORDER BY id")
if limit:
sql.append(f"LIMIT {int(limit)}")
rows = conn.execute("\n".join(sql), args).fetchall()
out = []
for did, wid, payload in rows:
fields = (payload or {}).get("字段") or {}
# 只留"演变类字段里存在带窗前缀条目"的卡(含内嵌章号但无窗前缀的条目不算需迁)
hit = any(k in EVOLUTION_FIELDS and isinstance(v, list)
and any(parse_prefix(x) for x in v if isinstance(x, str))
for k, v in fields.items())
if ids or hit: # 显式点名的卡即使无命中也纳入(验收要看"无可迁"边界)
out.append((did, wid, payload))
return out
def scan_pollution(payload):
"""当前态字段污染报告:非演变类字段里带 [窗N] 的条目数(仅提示,不迁)。返回 {字段: 条数}。"""
rep = {}
for k, v in ((payload or {}).get("字段") or {}).items():
if k in EVOLUTION_FIELDS:
continue
vals = v if isinstance(v, list) else [v]
n = sum(1 for x in vals if isinstance(x, str) and parse_prefix(x))
if n:
rep[k] = n
return rep
# ── LLM 精确化(读窗源文 → chat_governed 批量定位;连接纪律:读源文短连接、LLM 无连接)──
def llm_locate_prompt(title, a, b, text, entries):
"""构造"把若干本窗演变条目定位到真实章号"的 prompt。"""
lines = "\n".join(f"{i + 1}. {e}" for i, e in enumerate(entries))
return f"""【功能指令(存量升格卡·演变条目真实章号精确化)】
下面是《{title}》第 {a}-{b} 章的正文,和若干条"确定发生在这段正文里、但没标注精确章号"的演变条目。
为每条在正文里找到它对应的**真实章号**(正文各章以「## 第N章」开头)。找不到确切章的返回 null,绝不猜。
【本窗正文(第 {a}-{b} 章)】
{text}
【待定位条目】
{lines}
【输出规则(只输出一个 JSON 对象)】
{{"定位": [{{"序号": 1, "章": 章号数字或null}}]}}"""
def llm_precise(work_id, title, win_ranges, win_text, pending):
"""对 {窗号: [条目文本…]} 逐窗 LLM 定位,返回 {(窗号, 条目文本): 章号}。
win_text 预读的窗源文(连接已释放);LLM 走 chat_governed(全局额度治理,不自写降级)。"""
located = {}
for wno, entries in pending.items():
if wno not in win_ranges or wno not in win_text:
continue
a, b = win_ranges[wno]
prompt = llm_locate_prompt(title, a, b, win_text[wno], entries)
content, _usage, used = chat_governed(prompt, system=pu.IDENTITY)
if used is None: # 全局降级链耗尽:本窗放弃精确化,条目留待人工
print(f" [窗{wno}] LLM 全链耗尽,该窗 {len(entries)} 条转待人工", file=sys.stderr)
continue
try:
data = extract_json(content)
except Exception as e:
print(f" [窗{wno}] LLM 输出解析失败({str(e)[:60]}),该窗转待人工", file=sys.stderr)
continue
for r in [x for x in (data.get("定位") or []) if isinstance(x, dict)]:
idx, ch = r.get("序号"), r.get("章")
if isinstance(idx, int) and 1 <= idx <= len(entries) and isinstance(ch, int):
located[(wno, entries[idx - 1])] = ch
return located
# ── dry-run 主流程 ──
def plan_card(payload, win_max, located, llm_ran):
"""算单卡迁移计划(不写库)。返回 (字段级前后对照 dict, 来源计数 dict, 待人工数)。
located:{(窗号,条目文本): 真实章号} LLM 精确化结果(无则空 dict)。
llm_ran:该卡所属作品本次是否真跑了 LLM 精确化——决定真窗号条目未定到时算「待人工」还是仍留「待LLM」。"""
t = payload.get("type", "")
fields = payload.get("字段") or {}
diffs, srcs, pending_manual = {}, {}, 0
milestones = [] # 实体型:各演变类字段汇入的里程碑
for k, v in fields.items():
if k not in EVOLUTION_FIELDS or not isinstance(v, list):
continue
new_rel = [] # 关系型:演变轨迹换章号后的字符串条目
for entry in v:
if not isinstance(entry, str):
continue
if pu._is_garbage(entry): # 顺手清脏:结构垃圾条目丢弃
srcs["垃圾丢弃"] = srcs.get("垃圾丢弃", 0) + 1
continue
ch, src, wno = resolve_chapter(entry, win_max)
if src == "待LLM": # 真窗号条目:回填 LLM 精确化结果
body = pu._strip_tail(pu._strip_prefix(entry))
hit = located.get((wno, body))
if hit is not None:
ch, src = hit, "LLM定位"
elif llm_ran:
ch, src = None, "待人工" # 跑了 LLM 仍定不到 → 真待人工
# 否则(未跑 LLM):保持 src="待LLM"、ch=None,不计入待人工(待精确化,非定不了)
srcs[src] = srcs.get(src, 0) + 1
if src == "待人工":
pending_manual += 1
if t == RELATION_TYPE:
new_rel.append(migrate_relation_entry(entry, ch, src))
else:
milestones.append(migrate_entity_entry(entry, ch, src))
if t == RELATION_TYPE and new_rel:
# 去重(同文)+ 按章号排序(抽条目内嵌章号)
seen, dedup = set(), []
for x in sorted(new_rel, key=lambda s: pu._chapter_sort_key(pu._extract_inline_chapter(s))):
key = pu._strip_prefix(x).strip()
if key and key not in seen:
dedup.append(x); seen.add(key)
diffs[k] = {"前": v, "后": dedup}
if t != RELATION_TYPE and milestones:
# 实体型:成长弧线等 → 演变历程(对象化、去重按台阶、排序按章号);原演变类字段清空(回归未来计划)
seen, dedup = set(), []
for m in sorted(milestones, key=lambda m: pu._chapter_sort_key(m.get("章"))):
key = m["台阶"].strip()
if key and key not in seen:
dedup.append(m); seen.add(key)
old_evo = fields.get("演变历程") if isinstance(fields.get("演变历程"), list) else []
diffs["演变历程"] = {"前": old_evo, "后": dedup}
for k in EVOLUTION_FIELDS - {"演变历程"}:
if isinstance(fields.get(k), list) and fields[k]:
diffs[k] = {"前": fields[k], "后": []} # 已发生台阶搬走,原字段清空
return diffs, srcs, pending_manual
@click.command()
@click.option("--work-id", type=int, help="限定作品(不给=全部 upgrade_book 作品)")
@click.option("--ids", help="逗号分隔的卡 id 白名单(验收指定样例卡)")
@click.option("--limit", type=int, default=0, help="最多处理卡数(0=不限)")
@click.option("--llm", "use_llm", is_flag=True,
help="对真窗号条目真调 LLM 读原文精确化(默认关=纯机械预览,真窗号条目暂标待LLM)")
@click.option("--max-llm-windows", type=int, default=3, show_default=True,
help="LLM 精确化最多读几个窗(控额度,验收演示用)")
@click.option("--samples", type=int, default=5, show_default=True, help="打印几张卡的前后对照")
def main(work_id, ids, limit, use_llm, max_llm_windows, samples):
"""存量 [窗N]→真实章号迁移 dry-run(零写库)。"""
id_list = [int(x) for x in ids.split(",") if x.strip()] if ids else None
# ── 短连接①:读窗号上限 + 待迁卡(读完即释放)──
with psycopg.connect(DSN) as conn:
win_max_map = load_window_maxima(conn)
cards = load_target_cards(conn, work_id, id_list, limit)
click.echo(f"待迁卡:{len(cards)} 张(演变类字段含 [窗N] 前缀条目)")
if not cards:
return
# ── 无连接:首轮机械解析,收集各作品待 LLM 精确化的 (窗号→条目) ──
pending_by_work = {} # work_id -> {窗号: set(条目body)}
for did, wid, payload in cards:
win_max = win_max_map.get(wid, 10 ** 9)
for k, v in ((payload or {}).get("字段") or {}).items():
if k not in EVOLUTION_FIELDS or not isinstance(v, list):
continue
for entry in v:
if not isinstance(entry, str) or pu._is_garbage(entry):
continue
_ch, src, wno = resolve_chapter(entry, win_max)
if src == "待LLM":
body = pu._strip_tail(pu._strip_prefix(entry))
pending_by_work.setdefault(wid, {}).setdefault(wno, set()).add(body)
# ── LLM 精确化(可选):短连接②读窗源文→释放→无连接调 chat_governed ──
located_by_work = {} # work_id -> {(窗号,body): 章号}
if use_llm and pending_by_work:
for wid, wmap in pending_by_work.items():
win_nos = sorted(wmap)[:max_llm_windows] # 控额度:只精确化前 N 个窗
with psycopg.connect(DSN) as conn: # 短连接读源文
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s",
(wid,)).fetchone()[0]
win_ranges = load_win_ranges(conn, wid, win_nos)
win_text = {w: pu.load_window_text(conn, wid, *win_ranges[w])
for w in win_nos if w in win_ranges}
# 连接已释放,此处纯 LLM(chat_governed 全局治理)
pend = {w: sorted(wmap[w]) for w in win_nos}
located_by_work[wid] = llm_precise(wid, title, win_ranges, win_text, pend)
skipped = sum(len(wmap) for wmap in pending_by_work.values()) - \
sum(min(len(wmap), max_llm_windows) for wmap in pending_by_work.values())
if skipped:
click.echo(f"(LLM 精确化受 --max-llm-windows={max_llm_windows} 限,"
f"{skipped} 个窗本次未精确化、其条目暂计待人工)")
elif pending_by_work:
n = sum(len(b) for wmap in pending_by_work.values() for b in wmap.values())
click.echo(f"(--llm 未开:{n} 条真窗号条目暂标『待LLM』,加 --llm 真调原文精确化)")
# ── 无连接:出迁移计划 + 汇总统计 + 样例前后对照 ──
total_src, total_manual, changed_cards = {}, 0, 0
printed = 0
for did, wid, payload in cards:
win_max = win_max_map.get(wid, 10 ** 9)
diffs, srcs, manual = plan_card(payload, win_max, located_by_work.get(wid, {}),
use_llm and wid in located_by_work)
pollution = scan_pollution(payload)
for s, n in srcs.items():
total_src[s] = total_src.get(s, 0) + n
total_manual += manual
if diffs:
changed_cards += 1
if printed < samples:
printed += 1
name = payload.get("名称", "")
t = payload.get("type", "")
click.echo(f"\n{'=' * 70}\n卡#{did} [{t}] {name}(work={wid})")
if not diffs:
click.echo(" 演变类字段:无 [窗N] 条目可迁")
for k, d in diffs.items():
click.echo(f" ── 字段「{k}」:{len(d['前'])} 条 → {len(d['后'])} 条")
for x in list(d["前"])[:3]:
click.echo(f" 前│ {str(x)[:110]}")
for x in list(d["后"])[:3]:
click.echo(f" 后│ {json.dumps(x, ensure_ascii=False)[:110] if isinstance(x, dict) else str(x)[:110]}")
if pollution:
click.echo(f" ⚠ 当前态字段 [窗N] 污染(非演变台阶,建议 P0 重抽清理、本脚本不迁):"
+ ",".join(f"{k}×{n}" for k, n in pollution.items()))
# ── 总账 ──
click.echo(f"\n{'=' * 70}\n【迁移 dry-run 总账】(零写库,落库由主代理 gate 后另行发起)")
click.echo(f" 待迁卡 {len(cards)} 张,有实际迁移计划 {changed_cards} 张")
click.echo(f" 条目章号来源分布:" + ",".join(f"{s}×{n}" for s, n in sorted(total_src.items())))
click.echo(f" 待人工条目(章号实在定不了):{total_manual} 条")
if __name__ == "__main__":
try:
main()
except (psycopg.Error, RuntimeError) as e:
click.echo(f"[迁移错误] {type(e).__name__}: {e}", err=True)
sys.exit(1)

View File

@ -32,6 +32,10 @@ import psycopg
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts")) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
from parse_llm import m3_json, SensitiveHardStop, IDENTITY, TENANT, DSN # noqa: E402 from parse_llm import m3_json, SensitiveHardStop, IDENTITY, TENANT, DSN # noqa: E402
# 语义判重(P1)复用 embed skill 的嵌入通道(同模型同维、与检索端语义对齐)——
# 只在开启 --semantic-dedup 时才真调,默认关(试跑期嵌入延后,见文件头注释)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "embed" / "scripts"))
from embed_drafts import _session as _embed_session, embed_texts # noqa: E402
# ── 窗切割参数(方案 §五B:3–5 万字/窗、10–15 章,取保守双闸防观察输出过载)── # ── 窗切割参数(方案 §五B:3–5 万字/窗、10–15 章,取保守双闸防观察输出过载)──
WIN_MAX_CHARS = 35000 # 单窗正文字数上限 WIN_MAX_CHARS = 35000 # 单窗正文字数上限
@ -43,8 +47,19 @@ SOURCE_TYPE = "upgrade_book" # 独立来源标记:与范式卡 parse_book 隔
ENTITY_TYPES = ("character", "location", "item", "faction", "power_system", "event") ENTITY_TYPES = ("character", "location", "item", "faction", "power_system", "event")
RELATION_TYPE = "character_relation" RELATION_TYPE = "character_relation"
# 追加类字段白名单(值为数组的字段一律追加;这些字符串字段也强制追加、条目带窗号) # 追加类字段白名单(值为数组的字段一律追加)。「演变历程」是里程碑对象数组,走独立对象
APPEND_FIELDS = {"成长弧线", "演变轨迹", "大事记", "经历"} # 合并路径(MILESTONE_FIELDS);其余是字符串条目数组(历史上带 [窗N] 前缀)。「大事记」「经历」
# 是历史孤儿名(任何 schema 都没定义、会被合同守卫裁掉),保留仅为向后兼容旧数据、不再新用——
# 升格卡改造(2026-07-17)后已发生台阶统一记入「演变历程」。
APPEND_FIELDS = {"成长弧线", "演变轨迹", "大事记", "经历", "演变历程"}
# 里程碑对象数组字段:条目是 {章,台阶,周期} 结构化对象,去重按台阶内容、排序按真实章号
# (不用运行时窗号)——升格卡改造 P0 落点,区别于上面的字符串条目追加字段。
MILESTONE_FIELDS = {"演变历程"}
# 生命周期枚举(设计稿 §4.2):每条里程碑「周期」的取值域。
LIFECYCLE = ("登场", "成长", "高光", "退场", "结局")
# 语义判重(P1,设计稿 §8.2):召回同书近邻相似度 ≥ 此阈值才交 M3 终判。
DEDUP_SIM_THRESHOLD = 0.78
CHAP_BIG = 10 ** 9 # 章号缺失/待人工的里程碑,排序时排到最后
# ── 库内合同(元数据驱动公理:prompt 与守卫同源,禁手写合同)── # ── 库内合同(元数据驱动公理:prompt 与守卫同源,禁手写合同)──
@ -166,10 +181,11 @@ def observe_prompt(contracts, title, a, b, text, onstage):
onstage_lines = "\n".join(f"- {t}|{nm}|{brief}" for nm, (_, t, brief) in onstage.items()) or "(无)" onstage_lines = "\n".join(f"- {t}|{nm}|{brief}" for nm, (_, t, brief) in onstage.items()) or "(无)"
return f"""【功能指令(parse-book 作品面升格·实体观察)】 return f"""【功能指令(parse-book 作品面升格·实体观察)】
通读本窗正文,产出三类结果(只输出一个 JSON 对象): 通读本窗正文,产出三类结果(只输出一个 JSON 对象):
1) 新名字:正文出现、但「在场已知实体」清单里没有的实体(六型:{"/".join(ENTITY_TYPES)})。每个给:型、名称、别名、一句话摘要、按该型合同能填的字段(有正文证据才填)、出场章号。若你怀疑它其实是清单中某已知实体的别名/改名/化名,填「疑似别名指向」。 1) 新名字:正文出现、但「在场已知实体」清单里没有的实体(六型:{"/".join(ENTITY_TYPES)})。每个给:型、名称、别名、一句话摘要、按该型合同能填的字段(有正文证据才填)、出场章号。若你怀疑它其实是清单中某已知实体的别名/改名/化名,填「疑似别名指向」。若本窗已见其登场或首个进化台阶,按合同给「演变历程」的首条里程碑对象。
2) 已知实体新信息:清单中实体在本窗的实质新信息(境界变化/性格显露/重大经历/立场转变),一条 ≤60 字观察点。没有实质新信息的不要报。 2) 已知实体新信息:清单中实体在本窗的实质新信息(境界变化/性格显露/重大经历/立场转变),一条 ≤60 字观察点。没有实质新信息的不要报。
3) 纯出场:清单中实体本窗出现但无实质新信息的,只报名称+出场章。 3) 纯出场:清单中实体本窗出现但无实质新信息的,只报名称+出场章。
纪律:一次性龙套(单章无名或仅路过)不报进新名字;实体判据与字段以合同为准,无证据不填;不脑补。数值(战力/指数/排名等)必须正文原样出现才可写,禁止推算或编造。组织改组/合并产生的新组织是**新实体**(走新名字),不是旧组织的别名。「疑似别名指向」只在确为同一实体改名/化名时填。 纪律:一次性龙套(单章无名或仅路过)不报进新名字;实体判据与字段以合同为准,无证据不填;不脑补。数值(战力/指数/排名等)必须正文原样出现才可写,禁止推算或编造。组织改组/合并产生的新组织是**新实体**(走新名字),不是旧组织的别名。「疑似别名指向」只在确为同一实体改名/化名时填。
里程碑纪律(凡填「演变历程」字段必守):每条是对象 {{"章": 正文原样出现的真实章号(整数如 420,跨多章连续事件用区间字符串如 "420-423"), "台阶": "进化到什么+靠什么事件的一句话", "周期": 登场/成长/高光/退场/结局 之一}};**必须用真实章号,禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代**(卡会脱离运行环境被单独阅读);抽不出完整对象时至少给 {{"章","台阶"}},别整条丢;当前态字段(品阶/能力与限制/摘要等)只写"现在什么样"的干净值,历史进化流水一律进「演变历程」、不许塞进当前态字段。
【六型字段合同】 【六型字段合同】
{render_entity_contracts(contracts, ENTITY_TYPES)} {render_entity_contracts(contracts, ENTITY_TYPES)}
@ -194,7 +210,9 @@ def update_prompt(contracts, title, a, b, text, cards_with_obs):
return f"""【功能指令(parse-book 作品面升格·卡增量更新)】 return f"""【功能指令(parse-book 作品面升格·卡增量更新)】
下列每张卡给出「当前卡全文」与「本窗观察点」。对照本窗正文,**只输出需要变更的字段**: 下列每张卡给出「当前卡全文」与「本窗观察点」。对照本窗正文,**只输出需要变更的字段**:
- 覆写类字段(性格底色/说话方式/当前状态等标量):**必须输出该字段完整的新全量值**——旧值里仍然成立的信息要保留进新值,禁止只写"新增…"式增量(那会把旧信息抹掉); - 覆写类字段(性格底色/说话方式/当前状态等标量):**必须输出该字段完整的新全量值**——旧值里仍然成立的信息要保留进新值,禁止只写"新增…"式增量(那会把旧信息抹掉);
- 追加类字段(成长弧线/演变轨迹等数组):只输出本窗新增条目(不要重抄旧条目,不要自己加 [窗N] 前缀,系统会加); - 里程碑字段(演变历程):每当实体发生境界/代际/形态/能力的跃迁,或到达登场/高光/退场/结局节点,**追加**一条里程碑对象 {{"章": 真实章号(整数如 420 或跨章区间字符串 "420-423"), "台阶": "进化到什么+靠什么事件的一句话", "周期": 登场/成长/高光/退场/结局 之一}}——只输出本窗**新增**里程碑(不重抄旧条目,不输出 _win 等内部键);章必须是正文原样章号、**禁 [窗N] 与"本窗/近期"相对指代**;抽不出完整对象时至少给 {{"章","台阶"}},别整条丢;
- 其他追加类字段(成长弧线=未来计划、演变轨迹等数组):只输出本窗新增条目(不要重抄旧条目,不要自己加 [窗N] 前缀,系统会加);
- **当前态字段干净纪律**:品阶/能力与限制/当前状态/一句话摘要等只写"现在是什么样"的全量当前值——历史进化流水一律进「演变历程」里程碑,**绝不把成长史塞进当前态字段**(这是老卡把升级线塞进「戏剧作用/流转计划/跨体系换算」污染字段语义的病根,务必避免);
- 没有变化的字段不要输出;整卡无实质变化则不输出该卡; - 没有变化的字段不要输出;整卡无实质变化则不输出该卡;
- 该实体的身份/处境已发生重大变化时,把「一句话摘要」也作为变更字段输出(写当前态全量)。 - 该实体的身份/处境已发生重大变化时,把「一句话摘要」也作为变更字段输出(写当前态全量)。
纪律:以正文为证据,不脑补;数值必须正文原样出现,禁止推算;字段 key 必须来自该型合同;**禁止「本窗/本段/近期」等相对时间指代**——写绝对状态或带章号(卡会脱离本窗被单独阅读);只写**该实体自己**的信息(他人的任命/心理/变化不得写入本卡);每卡别名有新发现可在「别名新增」里给——只收**该实体自己**的新别名(他人对它的称呼算,它对别的实体的称呼不算),且必须是可在正文原样出现的纯名字(禁带括号注释与说明文字,禁单字)。 纪律:以正文为证据,不脑补;数值必须正文原样出现,禁止推算;字段 key 必须来自该型合同;**禁止「本窗/本段/近期」等相对时间指代**——写绝对状态或带章号(卡会脱离本窗被单独阅读);只写**该实体自己**的信息(他人的任命/心理/变化不得写入本卡);每卡别名有新发现可在「别名新增」里给——只收**该实体自己**的新别名(他人对它的称呼算,它对别的实体的称呼不算),且必须是可在正文原样出现的纯名字(禁带括号注释与说明文字,禁单字)。
@ -300,6 +318,90 @@ def _entry_win(x):
return int(m.group(1)) if m else 0 return int(m.group(1)) if m else 0
# ── 里程碑对象(升格卡改造 P0):{章,台阶,周期} 结构化条目的清洗/合并 ──
# 章级排序不用运行时窗号,而用真实章号(设计稿 §6.1「真实章号索引」的落点)。
# 内嵌真实章号抽取:正文/台阶里原样出现的「第X章 / X章 / Ch.X」——迁移与降级共用
INLINE_CHAP_RE = re.compile(r"第\s*(\d+)\s*章|(?:Ch|CH|ch)\.?\s*(\d+)|(?<![0-9])(\d+)\s*章")
def _extract_inline_chapter(text):
"""从台阶文本抽第一个内嵌真实章号(第X章/ChX/X章),抽不到返回 None。"""
m = INLINE_CHAP_RE.search(str(text))
if not m:
return None
return int(next(g for g in m.groups() if g))
def _chapter_sort_key(ch):
"""里程碑排序键:从「章」值(整数 / 区间字符串"420-423" / None)取起点章号;
缺失或非法排到最后(CHAP_BIG),保证"待人工"条目不插进正常时间线中间。"""
if isinstance(ch, int):
return ch
if isinstance(ch, str):
m = re.search(r"\d+", ch)
if m:
return int(m.group())
return CHAP_BIG
def _infer_lifecycle(text):
"""从台阶文本启发式推断生命周期枚举(模型未给或非法「周期」时的降级填充)。
诚实边界:这是关键词启发式、非精确判定;新抽取由提示词强制模型直接给枚举,此路仅兜底。"""
t = str(text)
if any(w in t for w in ("登场", "首次", "初次", "出场", "诞生", "创立", "问世", "面世")):
return "登场"
if any(w in t for w in ("退役", "封存", "陨落", "覆灭", "销毁", "谢幕", "退场", "解散", "湮灭")):
return "退场"
if any(w in t for w in ("结局", "终局", "最终", "决战", "了结", "落幕")):
return "结局"
if any(w in t for w in ("巅峰", "高光", "对决", "突破", "解放", "觉醒", "封神", "登顶", "碾压")):
return "高光"
return "成长"
def _clean_milestone(item, win_no):
"""规范化单个里程碑为 {章,台阶,周期,_win};垃圾/空台阶返回 None。
- dict 入参:取 章/台阶/周期;台阶剥前缀+剥尾残;缺章从台阶抽内嵌章号兜底;缺/非法周期启发式推断。
- 字符串入参(模型降级输出或存量迁移):整串当台阶,抽内嵌章号当章,推断周期。
- _win 盖当前窗号,仅作撤销溯源(undo_window 按它删本窗新增),不参与展示与排序。
降级保底(设计稿 §九 风险2 + 拍板#1):抽不出完整对象也退成"章号+一句话"最小对象,绝不整条丢。"""
if isinstance(item, dict):
step = _strip_tail(_strip_prefix(str(item.get("台阶") or item.get("阶") or "")))
ch = item.get("章")
cycle = item.get("周期")
else:
step = _strip_tail(_strip_prefix(str(item)))
ch, cycle = None, None
if not step or _is_garbage(step):
return None
# 章号:对象已给(整数或含数字的区间字符串)就用;否则从台阶文本抽内嵌章号;再无则 None(待人工)
if not (isinstance(ch, int) or (isinstance(ch, str) and re.search(r"\d", ch))):
ch = _extract_inline_chapter(step)
if cycle not in LIFECYCLE:
cycle = _infer_lifecycle(step)
return {"章": ch, "台阶": step, "周期": cycle, "_win": win_no}
def _merge_milestones(old, items, win_no):
"""合并里程碑数组:去重按台阶内容、排序按真实章号。返回 (merged, rejected)。
old 中已有对象保留其原 _win(不被本窗覆盖);新对象由 _clean_milestone 盖当前 win_no。
rejected 为被判垃圾的原始条目,交调用方留审计(对齐字符串路径的垃圾拦截)。"""
kept = [m for m in (old or []) if isinstance(m, dict) and m.get("台阶")]
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)
if not m:
rejected.append(it)
continue
key = m["台阶"].strip()
if key and key not in seen:
kept.append(m)
seen.add(key)
return sorted(kept, key=lambda m: _chapter_sort_key(m.get("章"))), rejected
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):
"""按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号), """按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号),
标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。 标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。
@ -349,6 +451,22 @@ def merge_card(conn, draft_id, win_no, changes, alias_new, valid_keys=None):
json.dumps(v.strip(), ensure_ascii=False), TENANT)) json.dumps(v.strip(), ensure_ascii=False), TENANT))
payload["一句话摘要"] = v.strip() payload["一句话摘要"] = v.strip()
continue continue
if k in MILESTONE_FIELDS:
# 里程碑对象数组(演变历程):走对象合并路径——去重按台阶内容、排序按真实章号,
# 不走下面处理字符串条目([窗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)
for rj in rejected:
# 垃圾/空台阶里程碑拒收留审计(对齐字符串路径的垃圾拦截守卫)
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, "垃圾拦截:演变历程",
json.dumps(rj, ensure_ascii=False)[:2000], TENANT))
fields[k] = merged
continue
is_append = k in APPEND_FIELDS or isinstance(v, list) or isinstance(fields.get(k), list) is_append = k in APPEND_FIELDS or isinstance(v, list) or isinstance(fields.get(k), list)
# 覆写值以增量口吻开头=模型把增量当全量(抽检#4:新值会抹掉旧基线),转追加不覆写 # 覆写值以增量口吻开头=模型把增量当全量(抽检#4:新值会抹掉旧基线),转追加不覆写
if not is_append and isinstance(v, str) and \ if not is_append and isinstance(v, str) and \
@ -449,6 +567,12 @@ def new_card(conn, work_id, win_no, ent):
# 原样入库——更新路守了、立卡路漏了):列表值逐条拆分、剥前缀、垃圾拦截、带窗号 # 原样入库——更新路守了、立卡路漏了):列表值逐条拆分、剥前缀、垃圾拦截、带窗号
fields0 = {} fields0 = {}
for k, v in (ent.get("字段", {}) or {}).items(): for k, v in (ent.get("字段", {}) or {}).items():
if k in MILESTONE_FIELDS:
# 里程碑字段(演变历程):初卡即走对象合并(登场/首个进化台阶),去重排序;
# 初卡尚无 draft_id 无法留审计,垃圾条目直接过滤(与下方字符串路径初卡同策略)。
merged, _ = _merge_milestones([], v, win_no)
fields0[k] = merged
continue
if not isinstance(v, list): if not isinstance(v, list):
fields0[k] = v fields0[k] = v
continue continue
@ -533,7 +657,11 @@ def undo_window(conn, work_id, win_no):
tag, changed = f"[窗{win_no}] ", False tag, changed = f"[窗{win_no}] ", False
for k, v in list(payload.get("字段", {}).items()): for k, v in list(payload.get("字段", {}).items()):
if isinstance(v, list): if isinstance(v, list):
nv = [x for x in v if not (isinstance(x, str) and x.startswith(tag))] # 字符串条目按 [窗N] 前缀删;里程碑对象(演变历程)按内部 _win 溯源键删——
# 对象无窗号前缀,撤销靠 _win 精确识别本窗新增,否则同窗重跑会 double-append。
nv = [x for x in v
if not (isinstance(x, str) and x.startswith(tag))
and not (isinstance(x, dict) and x.get("_win") == win_no)]
if len(nv) != len(v): if len(nv) != len(v):
payload["字段"][k], changed = nv, True payload["字段"][k], changed = nv, True
if changed: if changed:
@ -546,6 +674,97 @@ def undo_window(conn, work_id, win_no):
conn.commit() conn.commit()
# ── 语义判重(P1,设计稿 §8.2):打开 v6 已设计、暂时关着的「嵌入近邻 + M3 终判」那级 ──
# 治病根 4:机械判重只比名字字符串,改名("影杀者"→"IV代纯机械机甲·影杀者")/跨型指代就漏并。
# 默认关(--semantic-dedup 开启):语义召回依赖同书卡已 embed 落库;试跑期嵌入延后,无向量时优雅空转。
def _entity_embed_text(ent):
"""判重用嵌入文本:【型】名称:摘要 + 关键字段摘选(与检索端 embed 文本语义对齐)。
兼容新实体 ent(键:型/名称/一句话摘要/字段);下划线内部键(如 _win)不进嵌入文本。"""
t = ent.get("型") or ent.get("type") or ""
fields = ent.get("字段") or {}
body = "\n".join(f"{k}:{v}" for k, v in fields.items()
if v and k not in ("名称", "一句话摘要") and not str(k).startswith("_"))
return f"【{t}】{ent.get('名称', '')}:{ent.get('一句话摘要', '')}\n{body}"[:4000]
def recall_neighbors(conn, sess, work_id, ent, top=6):
"""算候选向量→在 example_knowledge_embedding 召回同书 ≥阈值 近邻卡。
返回 [(did, 型, 名称, 摘要, 相似度)…] 按相似度降序。
连接纪律:只读短查询,沿用窗内 conn(与 observe/update 同模式,keepalives 兜底)。
嵌入表无同书向量(试跑期未 embed)时返回 []——优雅降级,判重回退纯机械,绝不阻断主流程。"""
vecs, bad = embed_texts(sess, [_entity_embed_text(ent)])
if bad or not vecs or vecs[0] is None:
return []
qvec = json.dumps(vecs[0])
rows = conn.execute(
"""SELECT d.id, d.draft_payload->>'type', d.draft_payload->>'名称',
d.draft_payload->>'一句话摘要', 1 - (e.embedding <=> %s::vector) AS score
FROM example_knowledge_embedding e
JOIN muse_knowledge_draft d ON d.id = e.draft_id
WHERE e.tenant_id=%s AND e.deleted=FALSE AND d.deleted=FALSE
AND d.source_type=%s AND d.work_id=%s
ORDER BY score DESC LIMIT %s""",
(qvec, TENANT, SOURCE_TYPE, work_id, top)).fetchall()
return [(r[0], r[1], r[2], r[3], float(r[4])) for r in rows
if float(r[4]) >= DEDUP_SIM_THRESHOLD]
def dedup_judge_prompt(ent, neighbors):
"""M3 终判 prompt:候选新实体 vs 每个同书近邻,判 同一实体 / 前身 / 后继 / 无关。"""
nb = "\n".join(f"{i + 1}. 卡号{did}|{t}|{nm}|{brief or ''}"
for i, (did, t, nm, brief, _s) in enumerate(neighbors))
return f"""【功能指令(parse-book 作品面升格·语义判重终判)】
下面是一个"候选新实体"和若干"同书既有卡"(向量召回的近邻)。逐一判断候选与每张近邻卡的关系,四选一:
- 同一实体:同一对象的改名/化名/不同侧面(如"影杀者"与"IV代纯机械机甲·影杀者")——**仅同型可判**。
- 前身:候选是该近邻卡的上一代/来源(同一条进化链的相邻代际,如"铁头(一代)"之于"铁卫(二代)")。
- 后继:候选是该近邻卡的下一代/继承者。
- 无关:只是题材相近,各自独立。
判据:看名称/摘要/字段是否指向同一对象或同一条演变链;**跨型(如具体机甲 item vs 整套体系 power_system)绝不判同一实体,最多判前身/后继**——一整套体系不等于其中一台机体。
【候选新实体】
型={ent.get("型", "")}|名称={ent.get("名称", "")}|摘要={ent.get("一句话摘要", "")}
字段:{json.dumps(ent.get("字段", {}), ensure_ascii=False)[:600]}
【同书近邻卡】
{nb}
【输出规则(只输出一个 JSON 对象)】
{{"判定": [{{"卡号": 数字, "关系": "同一实体|前身|后继|无关"}}]}}"""
def semantic_dedup(conn, sess, work_id, ent):
"""语义判重裁决。返回 (verdict, data):
('merge', (did, 近邻名称)) 同型·同一实体 → 并卡(治改名漏并)
('chain', [(did, 关系, 名称)]) 前身后继 → 不并卡但记串链候选
('new', None) 无近邻或全判无关 → 各自立卡
同型才允许 merge;跨型即便 M3 判同一实体也降级为串链候选(设计稿 §8.2:跨型仅提示、不自动并)。
判重是增益非必需:召回失败/无向量/终判异常一律保守返回 new(不并可后补,误并难回退)。"""
neighbors = recall_neighbors(conn, sess, work_id, ent)
if not neighbors:
return "new", None
try:
data, _ = m3_json(dedup_judge_prompt(ent, neighbors), "MiniMax-M3", ("判定",))
except (SensitiveHardStop, RuntimeError):
return "new", None
nb_type = {did: t for did, t, _, _, _ in neighbors}
nb_name = {did: nm for did, _, nm, _, _ in neighbors}
ent_type = ent.get("型", "")
chain = []
for j in [x for x in (data.get("判定") or []) if isinstance(x, dict)]:
did, rel = j.get("卡号"), j.get("关系")
if did not in nb_type:
continue
same_type = nb_type[did] == ent_type
if rel == "同一实体" and same_type:
return "merge", (did, nb_name[did]) # 近邻按相似度降序,第一个同型同一实体即采
if rel in ("前身", "后继"):
chain.append((did, rel, nb_name[did]))
elif rel == "同一实体": # 跨型判同一实体不可信 → 降级串链候选
chain.append((did, "前身后继待定", nb_name[did]))
return ("chain", chain) if chain else ("new", None)
# ── 命令 ── # ── 命令 ──
@click.group() @click.group()
@ -568,9 +787,14 @@ def windows(work_id):
@click.option("--max-calls", type=int, default=0, help="本次 LLM 调用上限(0=不限,含敏感失败)") @click.option("--max-calls", type=int, default=0, help="本次 LLM 调用上限(0=不限,含敏感失败)")
@click.option("--model", default="MiniMax-M3", show_default=True) @click.option("--model", default="MiniMax-M3", show_default=True)
@click.option("--redo-window", type=int, default=0, help="指定窗号强制重跑(先撤销后重写)") @click.option("--redo-window", type=int, default=0, help="指定窗号强制重跑(先撤销后重写)")
def run(work_id, max_windows, max_calls, model, redo_window): @click.option("--semantic-dedup", "semantic_on", is_flag=True,
help="开启语义判重(P1):立卡前召回同书近邻+M3终判治改名/跨型漏并"
"(需同书已 embed 落库;默认关=试跑期嵌入延后)")
def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
"""按窗顺序跑升格:断点续跑跳过 done 窗;敏感硬停=窗 failed+书停。""" """按窗顺序跑升格:断点续跑跳过 done 窗;敏感硬停=窗 failed+书停。"""
calls = {"n": 0} # 调用计数(含敏感失败换模型的次数由 m3_json 内部消化,此处计成功轮次) calls = {"n": 0} # 调用计数(含敏感失败换模型的次数由 m3_json 内部消化,此处计成功轮次)
# 语义判重嵌入会话(仅开启时建;禁系统代理,走内网直连)
embed_sess = _embed_session() if semantic_on else None
def call(prompt, need_keys): def call(prompt, need_keys):
calls["n"] += 1 calls["n"] += 1
@ -673,6 +897,35 @@ def run(work_id, max_windows, max_calls, model, redo_window):
if len(chaps | hist) >= 2: # 跨章(含跨窗合计)→ 立卡 if len(chaps | hist) >= 2: # 跨章(含跨窗合计)→ 立卡
if hist: # 用留档补足初卡出场章 if hist: # 用留档补足初卡出场章
ent["出场章"] = sorted(chaps | hist) ent["出场章"] = sorted(chaps | hist)
# 语义判重(P1,--semantic-dedup 开启且同书已 embed 时生效):机械判重
# (名字/别名/子串)之后、立卡之前,召回同书近邻交 M3 终判治改名/跨型漏并。
if semantic_on:
verdict, vd = semantic_dedup(conn, embed_sess, work_id, ent)
if verdict == "merge": # 同型同一实体:并入既有卡,不另立
did0, canon = vd
to_update.setdefault(did0, []).append(
f"(语义判重·「{nm}」并入同一实体)初卡材料:"
f"{json.dumps(ent, ensure_ascii=False)[:400]}")
conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,'semantic',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, canon, nm, win_no, TENANT))
continue
if verdict == "chain": # 前身后继:仍立卡,串链关系记候选审计
did = new_card(conn, work_id, win_no, ent)
name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
# 只记候选提示、不自动写「前身/后继」字段——避免误串,链接由人工/后续确认落字段
for did2, rel, nm2 in vd:
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(did, win_no, ("串链候选:" + str(rel))[:100],
json.dumps({"对方卡号": did2, "对方名称": nm2},
ensure_ascii=False), TENANT))
continue
did = new_card(conn, work_id, win_no, ent) did = new_card(conn, work_id, win_no, ent)
name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", "")) name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
else: # 单章龙套 → 留档(G4) else: # 单章龙套 → 留档(G4)

View File

@ -27,7 +27,7 @@ SoT 对齐:包结构=专题-03 §4.2 **四层上下文**;字段级 aiContext 裁
- **Layer 2 作品事实**(一致性/规划不可省略;只读已确认): - **Layer 2 作品事实**(一致性/规划不可省略;只读已确认):
- **设定**:`设定.md` 四节;`generation` 用途裁掉「谜底与真相」「结局方向」「弃案记录」等非 generation 字段; - **设定**:`设定.md` 四节;`generation` 用途裁掉「谜底与真相」「结局方向」「弃案记录」等非 generation 字段;
- **大纲**:`generation` 只取主线一句话+当前卷细纲+近三章细纲;「未来卷粗纲」仅 `planning` 可见; - **大纲**:`generation` 只取主线一句话+当前卷细纲+近三章细纲;「未来卷粗纲」仅 `planning` 可见;
- **知识卡**:只取本章细纲出场清单涉及的人物/关系卡,及其**关联的地点/势力/功法/物品/事件卡**,**不整库倾倒**(planner/detector/extractor 按差异矩阵取全量索引);卡内字段同按 aiContext 裁——「创作备忘」永不进包,「成长弧线」续写不进(仅 planning/detection)。 - **知识卡**:只取本章细纲出场清单涉及的人物/关系卡,及其**关联的地点/势力/功法/物品/事件卡**,**不整库倾倒**(planner/detector/extractor 按差异矩阵取全量索引);卡内字段同按 aiContext 裁——「创作备忘」永不进包,「成长弧线」续写不进(仅 planning/detection);**「演变历程」(各实体型完整历史层的里程碑数组)续写不进(仅 detection/extraction)**——对续写关闭不是防剧透(台阶都已发生),而是怕上万字明细撑爆上下文;续写只需当前态+「演变概括」一句梗概,一致性检查才读全历史查跳级穿帮。
- **Layer 3 授权资料**(按场景可省): - **Layer 3 授权资料**(按场景可省):
- **公共范式**:仅 `装配.yaml` 已绑定的库;按本章场景类型选卡(如打斗章带打斗卡),不整库带入;extractor/judge 此层关闭(见差异矩阵)。 - **公共范式**:仅 `装配.yaml` 已绑定的库;按本章场景类型选卡(如打斗章带打斗卡),不整库带入;extractor/judge 此层关闭(见差异矩阵)。
4. 产出**上下文包**——两个顺序必须分开: 4. 产出**上下文包**——两个顺序必须分开:

View File

@ -0,0 +1,276 @@
# 升格卡改造设计(P0 补骨架记里程碑 · P1 串链语义判重)
- 版本:v1(2026-07-16 设计稿,供创始人评审,未实现、未动数据、未提交)
- 目标读者:创始人 + 后续实现的子代理
- 边界:只改**参考书作品面升格卡**(`source_type='upgrade_book'`)的字段骨架、抽取提示词、判重方式、上下文裁剪标注;不碰范式卡、不碰主仓。概念定义对齐 `../design-docs/`(专题-06、架构-02)与源方案 `docs/2026-07-14-参考书作品面数据入库方案.md`,本稿是对该方案 §五「实体卡生命周期」的一次修订,不自立门户。
- 一句话结论:给机甲/武器/力量体系这些型补上一条"从登场到结局"的**演变历程**明细,用**真实章号**当索引(不用会变的运行时窗号),并把这条上万字的明细**只给一致性检查看、不给续写看**(续写只看当前态和一句话梗概);再用**语义判重**把改了名、换了型的同一条成长线并回一起、串成前后链。
---
## 一、先说人话:这次要解决什么
升格卡 = 每本参考书里"会随剧情长大的实体卡"——人物、机甲、武器、力量体系都算。它的价值是:当 muse 学一本书时,能看清"这台生物机甲是怎么从 4 级一步步打到 7 级对决"的完整成长线。
现在这条线**维护不出来**。典型病例:一台生物机甲开头 4 级出场,中段 5 级 6 级,终局 7 级对决——卡里完全看不到这条演变线,只剩一个"最终态",中间全被抹平了。
这次改造分两步走:
- **第一步(先补骨架、记里程碑)**:给机甲/武器/力量体系这些型补上"演变历程"字段,把每一次进化台阶记成一条条里程碑,不再被覆写抹平、不再塞错字段。
- **第二步(再串链、上语义判重)**:加"前身/后继"把散落的碎卡串成一条进化链;判重从"只比名字字符串"升级到"比语义",治改名和跨型漏并。
两个硬要求贯穿全程:**里程碑用真实章号索引(不用窗号)**;**卡片分两层给——续写只看当前态,一致性检查才看全历史**。
---
## 二、病根复述(都有真实卡为证)
以下四条病根,均从库里真实升格卡(work_id=8)抽样核实,不是推断。
### 病根 1:只有"人物"型有成长字段,机甲/武器/力量体系型是"骨架残缺"
库里 23 型中,只有两个型有"记录演变"的数组字段:`character`(成长弧线)、`character_relation`(演变轨迹)。机甲/武器/战舰所属的 `item` 型、力量体系所属的 `power_system` 型,以及 `faction`/`location`/`event`,**一个演变字段都没有**。
- 铁证:机甲卡「铁头机甲(一代)」(id=1154)出场横跨 64 章(第 10 章到第 548 章),字段却只有"品阶/类别/知情范围/能力与限制",全是会被覆写的当前值。它的一句话摘要是"已退役,封存于暗龙会仓库"——这是**最终态**,把它 10 章出场时"崭新训练机"的样子彻底抹平了。一台陪跑全书的机甲,成长线为零。
### 病根 2:进阶要么被覆写抹平,要么塞进错字段成流水账
没有专门的演变字段,模型只能把"进化"硬塞进当前态字段,结果字段语义被污染:
- 机甲卡「影杀者」(id=4935):把"被重创 → 修复 → 配合突围"这条演变线,塞进了「流转计划」(本该写未来伏笔)和「能力与限制」(本该写当前能力),还带着"本窗/前窗"这种一旦脱离运行环境就没法读的相对指代。
- 力量体系卡「生物机甲」(id=6223,正是病例本体):把"第 420 章 V 代编队 → 477 章拦截 → 489 → 490-492 → 549-550 → 572 章"整条升级线,塞进了「戏剧作用」(本该一句话写规则)和「跨体系换算」(本该写强弱对照),写成一大段散文流水账。它的摘要同样是最终态(200% 同步率对决 VI 型领主),4 级出场的初始态被抹平。
- 对照「机甲世代」(id=4995)的「境界阶梯」="一代 < 二代 < 三代 < 四代 < 伪 V 代"——这是**静态的世界规则**(一把标尺),不是某台机甲实际爬台阶的历程。说明现有字段能记"规则",但记不了"某个实体自己怎么一步步长大"。
### 病根 3:一条成长主线被拆成 30+ 张碎卡,跨多个型,彼此无链接
"机甲代际"这条主线,散落在:力量体系型的「生物机甲」「机甲世代」「旧联邦机甲代际」,加上 item 型的「铁头机甲」「铁卫」「枪骑士」「影杀者」等一堆具体机甲卡。它们之间**没有任何链接**,看不出"铁头(一代)→ 铁卫(二代)→ 枪骑士(三代)→ 影杀者(四代)→ 生物机甲(V 代)"是同一条进化链。
### 病根 4:判重只比名字字符串,语义判重是关着的
现在的判重(在 `parse_upgrade.py` 的判重步)只有三招:名字/别名精确匹配、模型自报的疑似别名、同型名字互为子串。**没有接语义判重**——代码里白纸黑字写着"嵌入延后:试跑期不调嵌入 API"。
后果:同一实体一旦改名(如"影杀者"→"IV 代纯机械机甲·影杀者")或跨型指代,字符串对不上就漏并,进一步加剧病根 3 的碎卡。
> 补充:源方案 v6 本来就设计了"名字精确 → 嵌入近邻 → AI 终判"三级判重,只是试跑期把嵌入近邻这级**关掉了**。所以第二步的语义判重不是发明新东西,是**把 v6 已设计好、暂时关着的那一级打开**。
---
## 三、方案总览
```mermaid
flowchart TB
subgraph P0["第一步 P0:补骨架 + 记里程碑(治病根 1、2)"]
A1["给 item/power_system 等型<br/>加『演变历程』数组字段"]
A2["加进追加白名单 APPEND_FIELDS<br/>(只往后加,永不覆写抹平)"]
A3["改抽取提示词:<br/>每次进化记一条里程碑<br/>标生命周期(登场→高光→退场→结局)<br/>当前态字段保持干净、不塞历史"]
end
subgraph P1["第二步 P1:串链 + 语义判重(治病根 3、4)"]
B1["加『前身/后继』链接字段<br/>把碎卡串成一条进化链"]
B2["判重打开语义近邻(≥0.78)<br/>+ M3 终判 → 治改名/跨型漏并"]
end
subgraph FIX["两点硬性细化(贯穿全程)"]
C1["① 里程碑用真实章号索引<br/>绝不用运行时窗号 [窗N]"]
C2["② 卡片分两层披露<br/>续写只看当前态+梗概<br/>一致性检查才看全历史<br/>(复用现成的 aiContext 按用途裁剪)"]
end
P0 --> P1
C1 -.贯穿.-> P0
C2 -.贯穿.-> P0
```
---
## 四、第一步 P0:补骨架 + 记里程碑
### 4.1 加什么字段
给缺演变字段的实体型补一个统一的「演变历程」数组字段。核心是 `item`(机甲/武器/战舰)和 `power_system`(力量体系/机甲代际);`faction`/`location`/`event` 按需跟进(组织兴衰、地点易主、事件推进同样能用,建议同批补上,避免二次改)。
配套还要两个字段(详见第五节 schema 定义):
- 「演变概括」——一句话说清整条线的来龙去脉(现状层,续写可见)。
- 「前身」「后继」——串链用(第二步 P1 用,schema 一次性加齐)。
### 4.2 抽取提示词怎么改(要点,不写代码)
改 `parse_upgrade.py` 里的两段提示词:
- **实体观察提示词(observe)**:新实体立卡时,如果这一窗就观察到它的登场/首个进化台阶,产出「演变历程」的第一条里程碑(带真实章号 + 生命周期标记)。
- **卡更新提示词(update)**:从现在的"记新信息"改为明确的"**记进化台阶 + 生命周期**"——每当实体发生境界/代际/形态/能力的跃迁,或到达登场、高光、退场、结局这些节点,就**追加**一条里程碑,绝不覆写、绝不抹平旧台阶。同时要求:当前态字段(品阶/能力与限制/摘要)只写"现在是什么样"的干净全量值,**不许把历史流水塞进来**(直接治病根 2 的"塞错字段")。
提示词纪律要加两条硬约束:
1. 每条里程碑**必须带真实章号**(正文里原样出现的"第 X 章"),**禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代**——因为卡会脱离运行环境被单独阅读。
2. 生命周期标记从固定枚举里选:**登场 / 成长 / 高光 / 退场 / 结局**。这一条直接治"铁头机甲摘要跳到已退役、中间态全丢"——有了登场和退场节点,一台机甲什么时候来的、什么时候谢幕,一目了然。
### 4.3 合并逻辑要跟着改
「演变历程」进 `APPEND_FIELDS` 追加白名单后,`parse_upgrade.py` 的合并/立卡/撤销逻辑(`merge_card`/`new_card`/`undo_window`)要从"处理带 [窗N] 前缀的字符串条目"适配为"处理带章号的里程碑条目":去重按里程碑内容、排序按**章号**(不再按窗号)。现有那套针对脏数据的守卫(剥前缀、剥尾部残渣、拦垃圾、拆巨型粘连)要保留并改造,别丢。
> 顺带清理:`APPEND_FIELDS` 里的「大事记」「经历」两个名字在任何 schema 里都没有定义(是孤儿字段,模型就算输出也会被字段校验裁掉)。这次统一到「演变历程」,孤儿名可保留做向后兼容但不再新用。
---
## 五、「演变历程」字段的 schema 定义
### 5.1 字段清单(加在 item.yaml / power_system.yaml 等型的"特有字段")
| 字段名 | 类型 | 说明 | 属哪层 |
|---|---|---|---|
| 演变历程 | 里程碑对象数组 | 已发生的进化台阶明细,一条一台阶,永远追加不覆写 | 完整历史层 |
| 演变概括 | 一句话文本 | 整条线的来龙去脉梗概(如"4 级训练机出场 → V 代编队主力 → 300% 同步率对决 VI 型领主") | 现状层 |
| 前身 | 链接(卡号 + 名称) | 这张卡的上一代/来源,指向同书另一张卡 | 现状层 |
| 后继 | 链接(卡号 + 名称) | 这张卡的下一代/继承者,指向同书另一张卡 | 现状层 |
### 5.2 里程碑对象结构(每条演变历程条目长这样)
推荐用**极简三字段结构化对象**,既能按章号排序/定位,又不会因字段太多让模型抽崩:
```json
{
"章": 420,
"台阶": "V代编队首战:暗星帝国出动十台生物机甲配合亡灵号,扭转多瑙星海战场局势",
"周期": "高光"
}
```
- **章**:绝对章号,用于索引与排序。单章事件填整数(`420`);跨多章的连续事件填区间字符串(`"420-423"`)。**这是"用绝对章号索引"的落点**。
- **台阶**:一句话讲清"进化到什么 + 靠什么事件"。刻意把"结果 + 经过"合成一句,不再往下拆细字段,避免模型输出格式崩。
- **周期**:生命周期枚举,取值 `登场 / 成长 / 高光 / 退场 / 结局`。
这个结构是有真实依据的:库里关系卡「苏铭×林初雨」早期就自发用过 `{状态, 章区间: "Ch.21", 转折事件}` 这种带章号的结构化格式,证明模型抽得出、章号索引可行。三字段是"结构化可查询"和"模型稳定"之间的最优平衡点。
> 备选(若评审认为对象改造风险太大):退一步用**带章号前缀的字符串条目**,即把现在的 `[窗2] xxx` 换成 `[第420章·高光] xxx`。改动更小、迁移更平滑,但章号在文字前缀里,得靠正则抽取、不能直接用 SQL 过滤。推荐前者、备选后者,见第九节拍板点。
### 5.3 一条真实成长线改造后长什么样(以病例"生物机甲"示意)
现在(病根 2 的样子):升级史一大段塞在「戏剧作用」字段里,散文流水,摘要只剩最终态。
改造后:
- 演变概括(现状层,续写可见):"卡依斯生物反应驱动的奇袭兵器,从 4 级机体登场,历经 V 代编队、300% 同步率解放,终局对决近帝皇级 VI 型领主。"
- 演变历程(完整历史层,只给一致性检查):
- `{章: 332, 台阶: "生物机甲首次登场,作为突破哈姆斯要塞的核心奇袭兵器", 周期: 登场}`
- `{章: 420, 台阶: "V代编队首战,配合亡灵号扭转多瑙星海战场", 周期: 成长}`
- `{章: "490-492", 台阶: "苏铭精神链接同步率飙至300%,压制伪VI代、夺龙基之枪贯穿拜鲁特", 周期: 高光}`
- `{章: "549-550", 台阶: "300%同步率独战近帝皇级VI型英雄级伊琳莉丝,龙基之枪刺穿其AT立场", 周期: 结局}`
一条清清爽爽、按章号排序、能看出起承转合的成长线就立住了。
---
## 六、两点硬性细化的落地
### 6.1 细化①:真实章号索引 + 现有 [窗N] 卡的迁移办法
**为什么不能用窗号**:窗号([窗N])是抽取时"切正文窗"的运行产物——切窗规则是"每窗 12 章或 3.5 万字",规则一改窗号全变。它是运行时定义、会变、不该当卡的稳定属性。真实章号(第 420 章)才是永远不变的锚。
**存量卡怎么迁移**(一次性迁移脚本,本稿只给办法、不实现):遍历所有 `upgrade_book` 卡的演变类字段(成长弧线/演变轨迹等)里每一条带 [窗N] 的条目,按下面优先级把 [窗N] 换成真实章号:
1. **优先——抽条目文字里已有的真实章号**:很多条目正文里模型自己就写了"第 420 章""163 章""Ch.21",正则抽出来直接当「章」。这是最准的。
2. **兜底——用窗到章的映射表**:条目里没有内嵌章号的,查 `example_upgrade_window` 表(窗号 → from_chapter~to_chapter),把 [窗N] 映射成该窗的**章区间**(如 [窗2] → "13-24"),并打一个"章区间近似"标记,表明这是窗级近似、不是精确到章。
3. **顺手清脏**:迁移时一并清掉已知垃圾——`[窗17] ['窗23` 这类残片、重复条目、尾部 JSON 拼接残渣。复用 `parse_upgrade.py` 里现成的剥前缀/剥尾残/拦垃圾逻辑。
> 诚实边界:上万字的历史里,兜底那批(无内嵌章号、只能靠窗映射)会损失精度——只能定位到 12 章宽的区间。这是存量迁移的固有局限,新抽取的卡因为提示词强制带真实章号不受影响。
### 6.2 细化②:渐进式披露 = 现状层 + 完整历史层,接现有 aiContext 按用途裁剪
**这不是新机制**。本仓早就有"字段按用途可见性裁剪"的机制:每个 schema 字段有个 `aiContext` 标注——`true` 任何场景可见、`false` 一律不给、`[用途…]` 只有列出的场景可见。用途有四种:`generation`(续写)、`detection`(一致性检查)、`planning`(规划)、`extraction`(抽取判重)。`read-context`(组装上下文)、`search`(检索)、`detect`(一致性检查)三个 skill 都已经按这个标注干活。渐进披露就是**给演变字段填对 aiContext**,一行标注的事。
分两层:
- **现状层**——写作 agent 续写时默认只读这层。包括实体当前态(品阶/能力与限制/当前持有者/一句话摘要)+ 演变概括 + 前身后继。让续写者知道"这台机甲现在几级、一路怎么来的梗概、前身是谁",但不被上万字明细淹没。
- **完整历史层**——只有一致性检查(detector)读全。就是「演变历程」那个可能上万字的里程碑数组。续写用不上这么细,一致性检查却必须有它才能查出"上一章还 4 级、这一章怎么突然 7 级"这种跳级穿帮。
**用途可见性分层表**(这张表就是要填进各型 schema 的 aiContext 标注):
| 层 | 字段 | aiContext 标注 | 续写<br/>generation | 一致性检查<br/>detection | 规划<br/>planning | 判重<br/>extraction |
|---|---|---|:---:|:---:|:---:|:---:|
| 现状层 | 当前态(品阶/能力与限制/当前持有者) | `true` | ✓ | ✓ | ✓ | ✓ |
| 现状层 | 一句话摘要 | `true` | ✓ | ✓ | ✓ | ✓ |
| 现状层 | 演变概括 | `true` | ✓ | ✓ | ✓ | ✓ |
| 现状层 | 前身 / 后继 | `true` | ✓ | ✓ | ✓ | ✓ |
| **完整历史层** | **演变历程(全里程碑)** | **`[detection, extraction]`** | **✗** | ✓ | (可选) | ✓ |
关键就是最后一行:把「演变历程」标成 `[detection, extraction]`——续写(generation)自动看不到,一致性检查(detection)和判重(extraction)才看得到。填完这一行,三件事**自动发生、不用再写别的代码**:
1. `detect` skill 会自动把「演变历程」纳入检查项(它的规则就是"凡 aiContext 含 detection 的字段,自动成为一个检查项")——一致性检查从此多一道"演变连续性/跳级穿帮"关卡。
2. `read-context` 组装续写上下文时,自动把「演变历程」裁掉——写作 agent 的上下文不会被上万字历史撑爆。
3. `search --purpose generation` 检索时也自动裁掉它。
> 一个要讲清的区别:`character` 现有的「成长弧线」标的是 `[planning, detection]`,它的语义是"**计划中**的变化,防抢进度剧透"——防的是"未来"。而「演变历程」是"**已发生**的历史",它对续写不可见的原因不是防剧透(都发生过了),而是**怕上万字撑爆上下文**。两者都对续写关闭,但一个防未来、一个防过载,别混为一谈。也因此,关系卡的「演变轨迹」保持 `true`(续写可见)是合理的——俩人现在什么关系、怎么走到这一步,对续写笔触很重要,且量不大;而机甲的上万字升级史续写用不上,只需当前几级。
---
## 七、改动清单(涉及哪些文件 / schema / prompt / skill)
> 全部是设计稿层面的改动点,本稿不实现、不执行、不重跑种子、不提交。
| 文件 | 改什么 | 属 |
|---|---|---|
| `meta/schemas/item.yaml` | 加「演变历程」`[detection,extraction]`、「演变概括」`true`、「前身」「后继」`true` 四字段 | schema |
| `meta/schemas/power_system.yaml` | 同上 | schema |
| `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/db/scripts/seed_schemas.py` | 不改脚本;schema YAML 改完后**需重跑一次**把新字段和 aiContext 灌进库(本稿不跑,实施时跑) | 种子 |
| `.claude/skills/read-context/SKILL.md` | Layer 2 知识卡裁剪说明里,点名"演变历程完整层仅一致性检查可见、续写不给"(机制已支持,补一句说明) | skill 文档 |
| `.claude/skills/detect/SKILL.md` | 检查项示例表补一行"演变历程 → 演变连续性/跳级穿帮"(实际自动生成,文档补例) | skill 文档 |
| 新增一次性迁移脚本(如 `docs/` 或 skill scripts 下) | 存量卡 [窗N] → 真实章号 + 字符串条目 → 里程碑对象 + 清脏(见 6.1) | 迁移 |
**不需要改的**:数据库表结构(演变历程/前身/后继都是知识卡 JSON 里的字段,不需要 DDL 建表/改表);语义判重复用现成的 `example_knowledge_embedding` 向量表和 `embed`/`search` skill,不新建表。
---
## 八、第二步 P1:串链 + 语义判重(细化)
### 8.1 串链:前身/后继
用第五节已定义的「前身」「后继」链接字段(指向同书另一张卡的卡号 + 名称),把"铁头(一代)→ 铁卫(二代)→ 枪骑士(三代)→ 影杀者(四代)→ 生物机甲(V 代)"这条链串起来。链接机制照抄现成的做法——关系卡早就用"甲方卡号/乙方卡号"指向人物卡,一样的路子。标 `aiContext: true`,续写要知道"这台机甲的前身是谁"。
### 8.2 语义判重:打开嵌入近邻 + M3 终判
在判重步,除现有的名字精确匹配/子串外,新增(就是打开 v6 已设计、暂时关着的那级):
1. 对候选新实体算向量(走 `embed` skill),用 `search` 召回**同书同型**相似度 ≥ 0.78 的近邻卡。
2. 召回的近邻交给 M3 终判(走 `parse_llm.py` 的 `m3_json` 入口,构造"这两张卡是不是同一实体 / 是不是前身后继关系"的判断),判定三选一:**同一实体**(并卡)/ **前身后继**(不并卡但串链)/ **无关**(各自立卡)。
这样"影杀者"和"IV 代纯机械机甲·影杀者"这种改名、以及跨型指代同一条线的,都能被语义召回并正确处理。
**跨型判重要收着点**:`item`(具体某台机甲)和 `power_system`(一整套体系)很容易被向量拉近却其实不是一回事(如"生物机甲体系"vs 具体机甲"伊琳娅丝")。所以跨型只做"提示 + 串链候选",不自动并卡;同型才允许自动并。
---
## 九、风险与验证办法
### 风险
1. **条目从字符串变对象,合并逻辑改动面不小**。`parse_upgrade.py` 现有一大套针对字符串条目的守卫(剥前缀、剥尾残、拦垃圾、拆粘连、窗序归位),全要跟着改造成处理里程碑对象;存量卡还是字符串,迁移要兼容。→ 缓解:分两步落,先补字段和提示词(新卡受益),再做存量迁移;对象结构压到三字段降低崩坏面。
2. **模型抽结构化对象比抽字符串更容易格式崩**。→ 缓解:三字段极简;保留降级——抽不出完整对象就退化成"章号 + 一句话"最小对象,别整条丢。
3. **语义判重增加调用量和跨型误并风险**。→ 缓解:0.78 阈值 + 同型才自动并 + 跨型仅提示;判重近邻可像 v6 说的那样批量做、不逐条烧。
4. **存量迁移精度损失**:无内嵌章号的条目只能靠窗映射到 12 章宽区间。→ 接受为存量固有局限,新卡不受影响。
5. **改 schema 后必须重跑种子**,否则库里字段合同和 YAML 不一致,抽取会按旧合同把新字段裁掉。→ 实施清单里钉死"改 YAML 后重跑 `seed_schemas.py`"这一步。
### 验证办法(实施后怎么确认真治好了)
- **成长线立得住**:挑病例"生物机甲"这条线(work_id=8)跑改造后的抽取,看能不能维护出"4 级登场 → V 代 → 300% 高光 → 结局对决"这条按章号排序、带生命周期的清晰台阶,以及"铁头 →…→ 生物机甲"的前身后继链。
- **分层裁剪真生效**:`search --purpose generation` 查一张机甲卡,确认「演变历程」被裁掉、只回现状层;`--purpose detection` 确认「演变历程」保留。
- **一致性检查自动加项**:对一章跑 `detect`,确认检查清单里自动多了"演变连续性"这一项。
- **语义判重召回**:造一个改名样例(影杀者 / IV 代纯机械机甲·影杀者),确认语义判重能召回并由 M3 判为同一实体或前身后继。
- **章号无窗号残留**:迁移后全库扫一遍,确认演变类字段里不再有 [窗N] 前缀。
---
## 十、需要创始人拍板的点
> **✅ 已拍板(2026-07-17,创始人 4 项 + 主代理定 #4)——实现以此为准,下方原始理由留档:**
> 1. 里程碑格式 = **结构化对象(章 / 台阶 / 周期)**,配降级(抽不出完整对象退成"章号 + 一句话",不整条丢)。
> 2. 补齐范围 = **五型全补**:item / power_system / faction / location / event。
> 3. character = **一起改**:给人物也加「演变历程」、「成长弧线」回归"未来计划"本义,连带迁移 200+ 张存量人物卡。
> 4. 演变概括 = **独立字段**(主代理定:摘要管当前态、概括管轨迹,职责清晰)。
> 5. 存量迁移 = **人工精确化**:不接受 12 章宽近似——无内嵌章号的老条目靠再抽原文 / 人工核对精确到真实章号。
1. **里程碑用"结构化对象"还是"带章号前缀的字符串"?** 本稿推荐三字段对象(章/台阶/周期,可 SQL 按章排序、结构清晰),备选字符串前缀 `[第420章·高光] …`(改动最小、迁移最平滑)。取舍是"规整可查询"对上"改动小、模型稳"。
2. **演变字段是否给全部实体型补齐?** 核心必补 `item`/`power_system`。`faction`/`location`/`event` 建议同批补(组织兴衰、地点易主、事件推进同样用得上),避免将来二次改 schema。请拍板范围。
3. **`character` 型怎么处理?** 现在它把"已发生历程"记在名为「成长弧线(未来计划)」的字段里,语义拧巴。是否给 character 也加「演变历程」、让「成长弧线」回归"未来计划"本义?这会牵动 200+ 张存量 character 卡的迁移,请拍板做还是暂缓。
4. **「演变概括」是独立字段还是并进「一句话摘要」?** 本稿推荐独立字段(摘要管当前态、概括管轨迹,职责清晰);也可并进摘要写法要求省一个字段(更省、但摘要职责变重)。
5. **存量迁移的精度损失可接受吗?** 无内嵌章号的老条目只能定位到 12 章宽区间。确认接受,还是要投入人工/再抽一遍把这批精确化。

View File

@ -17,5 +17,12 @@ scope: entity
- { key: 行事逻辑, 说明: 面对冲突他会怎么选——行为一致性的判据, aiContext: true } - { key: 行事逻辑, 说明: 面对冲突他会怎么选——行为一致性的判据, aiContext: true }
- { key: 说话方式, 说明: 语言指纹:口头禅/句长/称呼习惯/敬语与粗口, aiContext: true } - { key: 说话方式, 说明: 语言指纹:口头禅/句长/称呼习惯/敬语与粗口, aiContext: true }
- { key: 秘密与底牌, 说明: 写手需要它才能写出言行的弦外之音, aiContext: true } - { key: 秘密与底牌, 说明: 写手需要它才能写出言行的弦外之音, aiContext: true }
- { key: 成长弧线, 说明: 计划中的变化轨迹,续写不可见防抢进度, aiContext: [planning, detection] } - { key: 成长弧线, 说明: 「未来计划」——计划中(尚未发生)的变化轨迹,续写不可见防抢进度剧透;已发生的台阶改归「演变历程」,二者分工不混, aiContext: [planning, detection] }
# ── 升格卡改造(2026-07-17)新增:演变历程(完整历史层,已发生台阶)+演变概括(现状层)。定义见 docs/2026-07-16-升格卡改造设计.md 第五节、拍板#3 ──
- key: 演变历程
说明: "已发生的成长台阶明细,一条一台阶只追加不覆写;每条是对象 {章: 绝对章号(整数如 420,或跨章区间字符串如 \"420-423\"), 台阶: 成长到什么+靠什么事件的一句话, 周期: 登场/成长/高光/退场/结局}。按真实章号排序索引(不用运行时窗号)——完整历史层,续写不给(防上万字明细撑爆上下文),仅一致性检查与判重可见;与成长弧线分工:本字段记已发生、成长弧线记未来计划"
aiContext: [detection, extraction]
- key: 演变概括
说明: "整条成长线的一句话梗概。现状层续写可见;与一句话摘要分工——摘要写当前态、本字段写来龙去脉"
aiContext: true
- { key: 创作备忘, 说明: 作者碎碎念,任何用途不入 AI 上下文, aiContext: false } - { key: 创作备忘, 说明: 作者碎碎念,任何用途不入 AI 上下文, aiContext: false }

View File

@ -14,3 +14,16 @@ scope: event
- { key: 因果后续, 说明: 它导致了什么、逼谁做了什么, aiContext: true } - { key: 因果后续, 说明: 它导致了什么、逼谁做了什么, aiContext: true }
- { key: 知情范围, 说明: 谁知道此事、知道到什么程度——「不该知道的人说漏嘴」是最常见穿帮, aiContext: true } - { key: 知情范围, 说明: 谁知道此事、知道到什么程度——「不该知道的人说漏嘴」是最常见穿帮, aiContext: true }
- { key: 事实状态, 说明: 已发生/预定发生;预定事件续写不可见, aiContext: [planning, detection] } - { key: 事实状态, 说明: 已发生/预定发生;预定事件续写不可见, aiContext: [planning, detection] }
# ── 升格卡改造(2026-07-17)新增:演变历程(完整历史层)+演变概括/前身/后继(现状层)。定义见 docs/2026-07-16-升格卡改造设计.md 第五节 ──
- key: 演变历程
说明: "已发生的推进台阶明细(事件多阶段发展:起因/激化/转折/收束),一条一台阶只追加不覆写;每条是对象 {章: 绝对章号(整数如 420,或跨章区间字符串如 \"420-423\"), 台阶: 推进到什么+靠什么触发的一句话, 周期: 登场/成长/高光/退场/结局}。按真实章号排序索引(不用运行时窗号)——完整历史层,续写不给(防上万字明细撑爆上下文),仅一致性检查与判重可见"
aiContext: [detection, extraction]
- key: 演变概括
说明: "整条推进线的一句话梗概。现状层续写可见;与一句话摘要分工——摘要写当前态、本字段写来龙去脉"
aiContext: true
- key: 前身
说明: "前置/起源事件,指向同书另一张卡(卡号+名称);把散落碎卡串成一条因果链"
aiContext: true
- key: 后继
说明: "衍生/后续事件,指向同书另一张卡(卡号+名称);把散落碎卡串成一条因果链"
aiContext: true

View File

@ -15,3 +15,16 @@ scope: entity
- { key: 地盘, 说明: 指向地点卡, aiContext: true } - { key: 地盘, 说明: 指向地点卡, aiContext: true }
- { key: 利益诉求, 说明: 它现在最想要什么——行为预测的依据, aiContext: true } - { key: 利益诉求, 说明: 它现在最想要什么——行为预测的依据, aiContext: true }
- { key: 与主角线的关系, aiContext: true } - { key: 与主角线的关系, aiContext: true }
# ── 升格卡改造(2026-07-17)新增:演变历程(完整历史层)+演变概括/前身/后继(现状层)。定义见 docs/2026-07-16-升格卡改造设计.md 第五节 ──
- key: 演变历程
说明: "已发生的进化台阶明细(组织兴衰:创立/扩张/内乱/覆灭),一条一台阶只追加不覆写;每条是对象 {章: 绝对章号(整数如 420,或跨章区间字符串如 \"420-423\"), 台阶: 演变到什么+靠什么事件的一句话, 周期: 登场/成长/高光/退场/结局}。按真实章号排序索引(不用运行时窗号)——完整历史层,续写不给(防上万字明细撑爆上下文),仅一致性检查与判重可见"
aiContext: [detection, extraction]
- key: 演变概括
说明: "整条演变线的一句话梗概。现状层续写可见;与一句话摘要分工——摘要写当前态、本字段写来龙去脉"
aiContext: true
- key: 前身
说明: "前身组织/来源,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true
- key: 后继
说明: "继承/分裂出的后续组织,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true

View File

@ -14,3 +14,16 @@ scope: entity
- { key: 当前持有者, 说明: 指向人物卡,随确认更新, aiContext: true } - { key: 当前持有者, 说明: 指向人物卡,随确认更新, aiContext: true }
- { key: 知情范围, 说明: 谁知道它的存在与真实能力, aiContext: true } - { key: 知情范围, 说明: 谁知道它的存在与真实能力, aiContext: true }
- { key: 流转计划, 说明: 它未来到谁手里、何时显威——伏笔,续写不可见, aiContext: [planning, detection] } - { key: 流转计划, 说明: 它未来到谁手里、何时显威——伏笔,续写不可见, aiContext: [planning, detection] }
# ── 升格卡改造(2026-07-17)新增:演变历程(完整历史层)+演变概括/前身/后继(现状层)。定义见 docs/2026-07-16-升格卡改造设计.md 第五节 ──
- key: 演变历程
说明: "已发生的进化台阶明细,一条一台阶只追加不覆写;每条是对象 {章: 绝对章号(整数如 420,或跨章区间字符串如 \"420-423\"), 台阶: 进化到什么+靠什么事件的一句话, 周期: 登场/成长/高光/退场/结局}。按真实章号排序索引(不用运行时窗号)——完整历史层,续写不给(防上万字明细撑爆上下文),仅一致性检查与判重可见"
aiContext: [detection, extraction]
- key: 演变概括
说明: "整条演变线的一句话梗概(如 '4级训练机出场→V代编队主力→300%同步率对决VI型领主')。现状层续写可见;与一句话摘要分工——摘要写当前态、本字段写来龙去脉"
aiContext: true
- key: 前身
说明: "上一代/来源,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true
- key: 后继
说明: "下一代/继承者,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true

View File

@ -13,3 +13,16 @@ scope: entity
- { key: 戏剧功能, 说明: 什么类型的戏适合在此发生, aiContext: true } - { key: 戏剧功能, 说明: 什么类型的戏适合在此发生, aiContext: true }
- { key: 规则特例, 说明: 此地不同于世界常态的法则(禁空/禁法…)——一致性检查点, aiContext: true } - { key: 规则特例, 说明: 此地不同于世界常态的法则(禁空/禁法…)——一致性检查点, aiContext: true }
- { key: 与主线关联, aiContext: true } - { key: 与主线关联, aiContext: true }
# ── 升格卡改造(2026-07-17)新增:演变历程(完整历史层)+演变概括/前身/后继(现状层)。定义见 docs/2026-07-16-升格卡改造设计.md 第五节 ──
- key: 演变历程
说明: "已发生的演变台阶明细(地点易主/兴废/规则变更),一条一台阶只追加不覆写;每条是对象 {章: 绝对章号(整数如 420,或跨章区间字符串如 \"420-423\"), 台阶: 演变到什么+靠什么事件的一句话, 周期: 登场/成长/高光/退场/结局}。按真实章号排序索引(不用运行时窗号)——完整历史层,续写不给(防上万字明细撑爆上下文),仅一致性检查与判重可见"
aiContext: [detection, extraction]
- key: 演变概括
说明: "整条演变线的一句话梗概。现状层续写可见;与一句话摘要分工——摘要写当前态、本字段写来龙去脉"
aiContext: true
- key: 前身
说明: "前身/旧称,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true
- key: 后继
说明: "重建/更名后的后续,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true

View File

@ -17,3 +17,16 @@ scope: entity
aiContext: true aiContext: true
设计发现: 2026-07-09《焚忆》立项——字段隐含多体系假设,建议 SoT 标可选或给「不适用」取值(待回填专题-06/后端-04) 设计发现: 2026-07-09《焚忆》立项——字段隐含多体系假设,建议 SoT 标可选或给「不适用」取值(待回填专题-06/后端-04)
- { key: 戏剧作用, 说明: 这套排序如何制造冲突与目标, aiContext: [planning, detection] } - { key: 戏剧作用, 说明: 这套排序如何制造冲突与目标, aiContext: [planning, detection] }
# ── 升格卡改造(2026-07-17)新增:演变历程(完整历史层)+演变概括/前身/后继(现状层)。定义见 docs/2026-07-16-升格卡改造设计.md 第五节 ──
- key: 演变历程
说明: "已发生的进化台阶明细,一条一台阶只追加不覆写;每条是对象 {章: 绝对章号(整数如 420,或跨章区间字符串如 \"420-423\"), 台阶: 进化到什么+靠什么事件的一句话, 周期: 登场/成长/高光/退场/结局}。按真实章号排序索引(不用运行时窗号)——完整历史层,续写不给(防上万字明细撑爆上下文),仅一致性检查与判重可见"
aiContext: [detection, extraction]
- key: 演变概括
说明: "整条演变线的一句话梗概(如 '4级训练机出场→V代编队主力→300%同步率对决VI型领主')。现状层续写可见;与一句话摘要分工——摘要写当前态、本字段写来龙去脉"
aiContext: true
- key: 前身
说明: "上一代/来源,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true
- key: 后继
说明: "下一代/继承者,指向同书另一张卡(卡号+名称);把散落碎卡串成一条进化链"
aiContext: true