剧情检索接线(Phase 5)
STORY RETRIEVAL
状态:🟢 三条检索线全部上线并验证 · 最后核对 2026-09-12 范围:把剧情侧的三份数据接进单聊与深聊 —— 经历(
story_memories.json)、时间线(story_timeline.json)、 人际关系(story_relations_index.json)。 抽取侧见 剧情事件抽取-首轮 与 全量运行复盘。
读者定位:本文记的是接线与召回这一层踩过的坑, 以及"为什么这个问题看不见"。想看"现在能答什么",直接看第 6 节。
0. 一句话
接线本身很小(三处 +=);难的是让"她答对了"这句话可信。
本轮共 13 个缺陷,全部表现为「她不知道」或「她答对了但不该那么答」, 而十三个原因各不相同:
| # | 缺陷 | 在哪节 | 表现 |
|---|---|---|---|
| 1 | recall.py 的路径改动没提交 |
§2 | 生产读旧路径 → 特性静默不可用 |
| 2 | 键形态:数据键是显示名,调用方传 id | §2 / §7 | 块为空 → 静默不注入 |
| 3 | 指令太弱("你记得的经历") | §2 | 块进了 prompt,被模型自己的知识覆盖 |
| 4 | 包含匹配污染("aak" in "阿梅利亚") |
§2 | Aak 指向阿梅利亚,真名是「阿」 |
| 5 | 同名冲突后写入的赢 | §2 / §7 | 幽灵鲨(119) 被 幽灵(3) 覆盖 → 空 |
| 6 | 大小写(specter ≠ Specter) |
§7 | 整批查不到 |
| 7 | 单字干员名全被排除(28 个) | §2 | 夕/令/年 整类召不回 |
| 8 | 同名不同人(临光 = Mlynar 也是 Nearl) |
§7 | 玛恩纳拿到 Nearl 的经历 |
| 9 | 关系索引按实体名归并 / 运行时按规范名查 | §7 | Nearl × 玛嘉烈 → 关系块空 |
| 10 | 干员名单认不出关系网里的 NPC | §7 | 陈 × 魏彦吾 → 关系块空 |
| 11 | 时间线排序两处错(分区归类 + 变体号) | §9 | 活动顺序倒置 |
| 12 | 样本硬截断在半句上 | §8 | 模型补出"当了十年建材公司职员" |
| 13 | ⚠️ 包含判据的方向("w" in "weiyi") |
§7 | 维伊/掠风都拿到 W 的经历 |
⚠️ 第 13 条是普查时才发现的 —— 前 12 条都是"碰到了一个具体的坏例子", 而第 13 条是**主动去问"同类问题还有多少"**才浮出来的(见 §13)。
⚠️ 十三条里十二条是我自己的代码,不是上游数据。
(只有 临光 同名算真数据歧义,但造成实质错误的是我的解析逻辑。)
⚠️ 它们的共同点是不报错、不崩、回答读起来正常 —— 所以下面每一节都记了**"这个问题为什么看不见"**,那比修法更值得记。
1. 接线本身
| 位置 | 说明 |
|---|---|
api/chat.py(单聊) |
插在一串并列的记忆块之后(L3 → 世界观 → L1 → 跨块回溯 → 剧情记忆) |
api/deep_chat.py(深聊) |
另一个装配点,同一份实现(app/systems/story/recall.py) |
| 收口 | 单聊在 personalize() 之前(那段注释说明了为什么不能更早);深聊在 LLM 出口统一做 |
⚠️ 没有在深聊里另写一套检索 —— 「同一个概念两份实现」是这个项目 最稳定的失败形状。
2. ⚠️ 六个静默失效 bug(全部由"拿原文/实测核对"逼出)
| # | 缺陷 | 表现 |
|---|---|---|
| 1 | recall.py 的路径改动没提交 |
生产读旧路径 → 整个特性静默不可用 |
| 2 | 键形态:数据键是显示名,调用方传id | 块为空 → 静默不注入 |
| 3 | 指令太弱("你记得的经历") | 块进了 prompt,但被模型自己的游戏知识覆盖 |
| 4 | 包含匹配污染("aak" in "阿梅利亚") |
Aak 指向阿梅利亚(81),真名是「阿」(12) |
| 5 | 同名冲突后写入的赢 | 幽灵鲨(119) 与 幽灵(3) 都是 Specter → 查到 3 条那个 → 空 |
| 6 | 单字干员名全被排除(28 个) | 夕/令/年 那类整类召不回 |
值得单记的两条
④ 是我自己写的,而我当时正在批评上游同一种。
build_story_index.py 里我写了 name in k or k in name,
理由是"凯尔希 vs 凯尔希·思衡托"。而 "aak" in "阿梅利亚" 纯属巧合,
却让真干员 Aak 的反查指向了阿梅利亚。
中文名同字同缀极多,包含判据必然出错。
现在只收:规范化后相等 / 括号内容 / 间隔号分隔的首段
(前缀必须有分隔符约束——无约束的前缀匹配就是包含匹配的变体)。
⑥ 的表现是最误导人的一种:答案看起来仍然正确。
第一版 mentioned_operators 写了 if len(name) < 2: continue(想防误命中),
代价是 28 个单字干员名永远无法通过"按在场人"召回到。
而模型会用它自己知道的答案补上——实测年/夕/令那组答得完全正确,
但没有一个字来自我们的数据。
3. ⚠️ 本轮最重要的结论:答案正确 ≠ 答案有据
这是 Phase 5 最该防的事,而它有两个实例:
实例一:召回 0 条,答案却全对
修单字名之前,年 × 你和夕、令是什么关系? 召回 0 条,而她答:
「妹妹。一个画画的,一个喝酒的——夕是十一妹,令是我姐。」
⚠️ 「夕是十一妹」「令是三姐」不在原文里(搜过"十一妹/三姐/排行", 只有 17 条无关命中:「三年前」「十一月」)。那是模型的游戏知识。
实例二:召回 1 条,但那 1 条与问题无关
年 × 你和玛恩纳是什么关系? → n=1,命中的是
「夕追问博士和凯尔希的事」—— 与玛恩纳毫无关系。
所以"条数"这个信号本身就是骗人的。
4. 为此加的东西:recall_diag()
判据不只是条数,还要能回答"命中的词覆盖了用户问的多少":
你和惊蛰是什么关系? n=2 部分=0 问及=[惊蛰] 覆盖=[惊蛰] 有据
你和玛恩纳是什么关系? n=1 部分=1 问及=[玛恩纳] 覆盖=[] ⚠️ 虚假有据
你和黍、望是什么关系? n=2 部分=1 问及=[黍,望] 覆盖=[望] 部分
你和夕、令是什么关系? n=4 部分=0 问及=[夕,令] 覆盖=[夕,令] 完整
⚠️ partial=1 是最该看的那一档:用户问了若干人/事,命中里只覆盖一部分 ——
模型最容易用自己知道的那一半补齐。 实测两例都落在这一档。
chat.py 的日志现在打判据(只打计数与名字,不打内容):
[剧情记忆] op=年 n=1 块=378 部分=1 问及=['玛恩纳'] 覆盖=[]
5. ⚠️ 但行为只改善了一半(诚实的结论)
块头加了「上面的经历可能不全,没有的就说想不起来,绝对不要用你自己的知识去补细节」。 实测效果:
| 情况 | 修之后的表现 | 判 |
|---|---|---|
| 有据(夕/令,覆盖完整) | 「我妹妹呗……一个把自己关进画里不肯出来的,一个整天喝酒装糊涂的」 | ✅ 完整且贴合 |
虚假有据(玛恩纳,n=1 覆盖 0) |
「谁?临光家那位叔叔?……不就走廊上打过几次照面」 | ❌ 仍编 |
无据(德克萨斯,n=0) |
「……见过,没聊过几句」 | ❌ 仍编 |
而数据里 年与玛恩纳同场 0 条、与德克萨斯同场 0 条 —— 「打过照面」「见过」是凭空来的。
所以:可观测性那半做成了,"让她不编"那半没做成。 比起"编出具体的错误亲属关系",现在更像含糊的搪塞("见过,没聊几句"), 但那仍然是编的。
可选的下一刀(需要权衡)
partial=1 或 n=0 时不注入块(连"你可能不记得"都不给),
只在完全无覆盖时降低她"随口认识"的倾向。
⚠️ 代价:重岳 × 你和黍、望 那种"部分覆盖但确实有据"的问答会一起被削
(实测那条答得很好:「我曾隔着梦境让他活着回来,他连一句保证都不肯给我」,
几乎逐字复现召回的 memory)。削哪一边要你定,我不替你决定。
6. 已验证的问答(生产实测)
⚠️ 这些同时进回归集(tests/eval/story_recall.json)——
手工验证的结论要留下来,否则下次改召回逻辑时没人记得它们验证过。
人际关系(目标②)
| 问 | 答 |
|---|---|
| 左乐 × 你和惊蛰 | 「她是我小姨。不过公事上分属大理寺与司岁台,各司其职,谈不上什么照应。」 |
| 左乐 × 你和惊蛰什么关系 | 「远亲,我唤她一声小姨——不是正经亲戚,只是父亲托她照看我,她便真拿长辈的架子管教起我来了。」 |
| 斯卡蒂 × 你和幽灵鲨 | 「同队。深海猎人第三大队……她比我早疯,也比我早醒。」 |
| 幽灵鲨 × 你认识斯卡蒂吗 | 「认识。——同一个深渊里爬出来的,不用说话也认得。」 |
| 玛恩纳 × 玛嘉烈和玛莉娅是你的什么人 | 「侄女。兄长的女儿。」 |
| 玛嘉烈 × 玛恩纳是谁 | 「我叔叔,临光家的长辈。也是那个说我最大的错,是替别人决定了他们人生轨迹的人。」 |
| 黍 × 你和重岳 | 「大哥。家里最硬的那根脊梁——风来了会弯,却永远不倒。」 |
| 陈 × 你和魏彦吾 | 「他是我舅舅,也是教我剑术和法术的人。曾经是我的上级——我已经辞职了。」 |
⚠️ 左乐那条把注入的三条关系全部复现了(远亲/小姨/不是正经亲戚 · 父亲托她照看 · 拿长辈架子管教),而且融进了她自己的语气。 陈那条同理(舅甥 / 师徒 / 上下级)。
剧情记忆(目标①)
| 问 | 答 |
|---|---|
| 阿米娅 × 你还记得最早的时候吗 | 「博士说的"最早",是指雷姆必拓的那一年吧——从车队残骸里被你抱出来,然后跟着你和暴行走了整整一年。」 |
| 重岳 × 你和黍、望是什么关系 | 「同胞兄弟罢了——同出岁兽,各执一面。望那孩子……我曾隔着梦境让他活着回来,他连一句保证都不肯给我。」 |
⚠️ 重岳那条几乎逐字复现召回的 memory (「我隔着梦境与现实的界限让望看见我怀中的造物……我让他活下去、活着回来, 他说不会向我保证什么」)—— 这是"有据"的最强证据形态。
时间线(目标③)
| 问 | 答 |
|---|---|
| 玛恩纳 × 你先后都经历过哪些事 | 「……临光小姐参加骑士竞技那阵,我拦下过玛莉娅——就这些,记着而已。」 |
| 临光(Nearl) × 你先后都经历过哪些事 | 「卡西米尔之后的事说来话长——黑暗时代那几场我去接过阿米娅和Ace,乌萨斯荒野里布置过抽压机和风仓。再往后是给格拉尼的那封信……」 |
⚠️ 这两条是对照组:修 同名不同人 之前,玛恩纳答的是
「黑暗时代那两场,苦难摇篮,怒号光明」——那是 Nearl 的戏份。
⚠️ 图谱侧的缺口(与上表对照着看)
临光家族(玛恩纳/临光/玛嘉烈):图谱 19 条边,**非同类 0 条** ❌
岁家(年/夕/令/重岳): 图谱 77 条边,**非同类 77 条** ✅
临光家的关系全靠剧情抽取(文本里的"叔叔"),图谱一条真关系边都没有。
已由 story_relations_index.json 补上(玛恩纳 131 条关系),
但图谱本身没修 —— 那是 Phase 4 的事。
7. ⚠️ 同一个名字,两个模块各有一张表(这是最反复的一族)
第 2 节列的六个 bug 里,有四个属于同一族。而在这轮的后半段 (时间线 + 关系接线)又踩了三次。所以单独立一节。
形状
同一个实体有两个标识,而只有一个能查到。 两者在日志上都表现为"查无结果"。
这一族踩过的全部实例
| # | 两个标识 | 谁和谁不一致 | 表现 |
|---|---|---|---|
| 1 | 显示名 左乐 / 干员 id zuole |
数据键 vs 调用方 | 块为空,静默不注入 |
| 2 | 大小写 Specter / specter |
精确匹配 vs 混合大小写 id | 整批查不到 |
| 3 | 同名冲突 幽灵鲨(119) / 幽灵(3) |
同 id 两个显示名 | 查到 3 条那个 → 空 |
| 4 | ⚠️ 临光 = Mlynar 也是 Nearl |
叔侄同名 | 玛恩纳拿到 Nearl 的经历 |
| 5 | 关系索引按 玛嘉烈 归并 / 查询找 临光 |
实体名 vs 规范名 | Nearl × 玛嘉烈 → 关系块空 |
| 6 | 干员名单(429 个)/ 关系网里的 NPC(5044 个) | 两张名字表 | 陈 × 魏彦吾 → 关系块空,而那 10 条关系就在数据里 |
各自的修法,以及第 4 条为什么最严重
1–3 用一张统一的别名表解决(小写键、冲突取经历最多)。
④ 的修法不同:用权威来源定音。.char 首行是规范标题
(Mlynar.char = 玛恩纳、Nearl.char = 临光),
而别名表只会按"经历最多"猜。权威 > 统计。
⚠️ 而 ④ 最严重的原因不是"查不到",是查到了别人的:
问玛恩纳「你先后都经历过哪些事」
答「黑暗时代那两场,苦难摇篮,怒号光明——跟博士你一起的」
读起来完全正常,而玛恩纳根本不在这三章里 —— 那是 Nearl 的戏份。
⚠️ 我当时把它记为"数据歧义,属待办"。记成待办的东西会一直躺在那里, 直到它造成一次实质性错误。 现在它造成了。
⑤⑥ 的修法是让判据来自被查的那份数据本身,而不是另一张表:
- ⑤ 索引构建时先过同名解析再归并
- ⑥ 新增
mentioned_in_relations()—— 用她自己关系网里的名字判"问到了谁"
⚠️ 一处修法本身也踩了坑(值得单记)
我给 ④ 加"规范名优先"时,把那段代码放在函数最前面,
然后被后面的"取经历最多"覆写回去了 —— 实测仍然解析到 临光。
「先设好再让别人改」不叫优先,叫顺序无关的巧合。 现在放在最末尾(所有其它规则之后),结构上不可覆写。
8. 三类"看起来没问题"的失败(都靠逐词回查才发现)
这一轮每个坑都不报错、不崩、回答读起来正常。它们的共性是: 模型遇到"看起来不完整"的东西会补,而补的部分看不出来。
| 我们给了它什么 | 它补了什么 | 真相 |
|---|---|---|
样本截断在半句(…她否认了。中途) |
「当了十年建材公司职员」 | 那两个词在数据里都找不到 |
partial=1(问了 2 人、只覆盖 1 人) |
把没覆盖的那半用自己的知识补上 | 年与玛恩纳同场 0 条 |
| 召回 0 条(单字名整类排除) | 「夕是十一妹,令是三姐」 | 原文里没有这些排行 |
⚠️ 所以修法都是同一条:把"不完整"变成显式的——
截断停在句边界 + 明说"可能不全、别补" + 加 recall_diag 判据。
而它们全部是"拿原文逐词回查"才发现的 —— 不是读代码,也不是看回答像不像。
9. 三条检索线各自的要点
| 经历(目标①) | 时间线(目标③) | 人际关系(目标②) | |
|---|---|---|---|
| 数据 | story_memories.json(19.7 MB) |
story_timeline.json |
story_relations_index.json(4.9 MB) |
| 回答什么 | 发生了什么 | 按什么顺序 | 她与谁有什么 |
| 入口 | recall() / build_block() |
timeline_of() / build_timeline_block() |
relations_for() / build_relations_block() |
| 判据 | 事件词 + 在场人 | 她有经历的事件 | 用户提到了谁 |
| 无命中时 | 返回空(不注入) | 少于 2 个事件则不注入 | 没人被提到则不注入 |
⚠️ 时间线:数据里没有全局先后,所以不能声称有
实测四路都试过:
ASTR 无日期字段(stage 只有 10 个字段)
R2 的事件索引 404
`story_info` 的「同时/之后/之前」是**幕内**叙事(60/28/14 条)
活动与主线**没有交叉引用**(54 个活动里 0 个提到 `main_`)
所以只能分三段并明说段间不声称先后:main_*(可靠)→ act\d+
(同序列内可靠)→ 密录/番外。"看起来精确的错序"比"少给点"坏得多。
⚠️ 而且第一版有两个排序 bug:MINI_ACTIVITY 整体排到 ACTIVITY 之后、
15 个主线分区全排最前面;段内还把变体号当了次序
(act13side 排到了 act13d5 前面)。
⚠️ 人际关系:note 是 100% 覆盖的,不要另写
实测 30,180 条关系里 note 100% 有值且已是自然语言
(如「舅甥关系,陈称魏彦吾为舅舅」)。所以不用再花一次 LLM 钱转一遍
(评估文档里原估 ¥30-50 做这件事 —— 那一步可以整个省掉)。
⚠️ 但不做改写:note 有时没用对名字(惊蛰:惊蛰以长辈和上级身份管教左乐
是第三人称转述却挂在"你和她"的框里)。目前只做减法(去重、带对方名字),
改写会引入原文没有的表述。
⚠️ 关系按 (谓词, 对方) 归并是必须的
同一个人在 30 个剧本里跟他都是"近亲",那是一条关系出现 30 次, 不是 30 条不同关系。不归并会让注入块里同一件事重复几十遍。
⚠️ 而去重键不能用 (谓词, note) —— 同一关系在归并时从两侧各登记一次,
两行 note 可能略有差异,于是 - 玛嘉烈:祖孙(转述) 连着出现两次。
判据要落在"是不是同一条关系"上,不是"是不是同一句话"。
10. 关系抽取全量(目标②的上游)
scripts/fetch_story_relations_batch.py → 2009/2009 剧本 · 30,180 条关系
(line_no/line_id 零缺失 · 只丢弃 8 条编造引用 = 0.03%)。
⚠️ 批处理带了单实例锁(复盘里记着"重复跑让 API 账单翻倍"那个坑), 并修了两个会挡住它的坑:
extract_story_relations.py缺max_tokens—— 截断在关系抽取里表现为"这个剧本没有关系",看起来像"这段剧情没人际关系"- 空剧本崩在
max()空序列(全库 1 个)——ValueError看起来像代码 bug,实际是数据里本来就没有内容
⚠️ 一处我自己的错误观察要记
我先前说「现有产物的 evidence 全是 line: null,所以要重跑补 line_id」——
那是查错键了:证据用的是 line_no,而 line 这个键根本不存在。
story_relations_to-st-1.json 其实一直是对的。
"重跑补 line_id"这条待办是我基于错误观察得出的,它不成立。 教训:查 schema 要先打印实际的键,别按记忆里的字段名去查。
11. 回归集(让手工验证的结论留下来)
tests/eval/story_recall.json + tests/test_story_regression.py ——
13 条正例 + 2 条负例。
⚠️ 断言的是检索层,不是模型的回答
LLM 输出不可复现(同一剧本跑三次得 19/28/27 个事件)。 所以锁的是数据层:给定问题必须召回到含某个事实的内容。 模型有没有用好那是另一件事(见第 5 节)。
⚠️ must_any 只放能在数据里逐词找到的东西
反例:「夕是十一妹」「令是三姐」听起来对,但原文里没有 —— 那是模型的游戏知识。那种词绝不能进回归集,否则测试会为"编得对"背书。
⚠️ 不确定的事实也不进:Nearl 那边有一条「玛嘉烈:祖孙」,
而按设定 Nearl 与玛嘉烈是姐妹 —— 我不确定是抽取错还是我记错,
所以那条只断言"查得到",不断言内容。
它当场就抓到一个真缺陷
负例 今天天气不错 判红:关系块非空(注入了凯尔希、博士等 8 条)。
根因是 relations_for 在"没人被提到"时返回"按证据强度排的前 8 条"。
⚠️ 而且它和经历块行为不一致:经历块无命中时是空的。 两个同级块一个"没命中就空"、一个"没命中也给一堆" —— 那种不一致本身就是缺陷,上层没法用同一套判据理解它们。
⚠️ 2026-09-15 补充:注入侧改成了两级(上面那条决定没被推翻)
用户报「裘里奥不认识结城理」,查出来是触发条件太窄:关系块要求完整名字出现在用户消息里
(名字 in query),而用户平时说的是「结城」(他自己的 L1 里就写着
「我看结城他们很担心你们呢」)→ 命中 0 → 关系块 0 字 → 她什么都不知道。
数据其实是全的:裘里奥的关系网 40 条、其中与结城理 9 条(含「实际承担监护人角色」)。
现在注入侧只有一个入口 build_people_block(op, query):
| 情况 | 注入什么 | 实测大小 |
|---|---|---|
| 消息里问到名字 | 完整关系块(含 note + 证据档位) | 512 字(≤8 条 / 700 字) |
| 没问到 | 「你认识的人」名单(只给名字 + 关系词) | 174 字 / 11 人(≤12 人 / 320 字) |
两者只出一个(不是叠加)。⚠️ 上面那条负例一个字没改:检索层
(relations_for / build_relations_block)行为不变,今天天气不错 仍然判空;
名单是另一个函数、另一个粒度(无注释、无证据档位、有界),并且带运行时开关
relations_roster(默认开、可按用户覆盖),关掉即完全回到旧行为。
判据在 tests/test_story_roster.py(含"旧边界还在"那条)。
12. 现状与遗留
规模
| 剧本(经历侧) | 2009/2009 · 18,663 个事件 |
| 关系 | 30,180 条 · 干员子集 422 / 429 |
| 时间线 | 480 个事件分区 |
| 干员 id 解析 | 353/353 正确(0 个串到别人身上) |
| story 相关测试 | 65 条(回归集 15 + 经历 27 + 名字解析 23) |
⚠️ 索引为什么破例进版本库
data/story_memories.json(19.7 MB)与 data/story_relations_index.json(4.9 MB)
都在 git 里,没有被忽略。理由:服务器上生成不出来 ——
它们的源(data/story_astr/ 138 MB、data/story_relations/ 41 MB)都是派生的、
也没进 git。不提交就只能靠 SSH 传文件,那绕过了「git 是部署通道」。
⚠️ .gitignore 里有一条坑要记:data/story_relations_*.json 这条通配
也匹配到了 story_relations_index.json(* 吃掉了 _index)。
我先把"不忽略"的说明写在下面 —— 而 git check-ignore 说它仍被忽略。
注释不等于规则,要 !data/story_relations_index.json 反向规则。
遗留
| 事 | 说明 |
|---|---|
partial=1 的注入策略 |
⏸ 先不削,当监控信号(scripts/analyze_story_injection.py)。削它会一起削掉"部分覆盖但确实有据"的好答案 |
临光 一类的同名歧义 |
✅ 已普查(§13):387 个 id 里 0 个还解析到别人;总闸测试盯着 |
| 覆盖率 422 / 429 | 7 个干员在关系侧查不到,没查是哪 7 个 |
| 图谱侧的「同族」 | 临光家族在图谱里 30 条边全是「同族」、真关系 0 条 —— 已由关系索引补上,但图谱本身没修 |
| 32 个"规范名不在数据里"的干员 | ✅ 已查清:是设计如此(§13 后续)—— .char 是游戏内代号,剧情用本名,数据给本名标的 operator_ids 只有这一个 id。生产实测灵知/斥罪都答对。有 2 条测试锁住它,免得被当 bug"修"掉 |
图谱 vs 本索引:两层,不混
静态图谱(relation_graph.json) 干员 ↔ 干员,302 节点,**设定级**
本索引(story_relations_index) 含剧情 NPC,按干员归并,**单章事件级**
⚠️ 评估文档里担心过"NPC 分层"(运行时只有 429 个干员、关系里有 5062 个实体名)。
那个担心是多余的 —— 关系是三元组,名字就写在边里,
不需要拿名字去查节点表。NPC 作为字符串出现在 other 字段,不占节点。
13. ⚠️ 普查:唯一一条不是"碰到坏例子"才发现的缺陷
§0 表里第 13 条(包含判据的方向)和前面十二条的发现方式不同。
前十二条都是被一个具体的坏例子打中:问左乐得到一个假答案、 问玛恩纳得到别人的经历、负例判红……都是先看到症状,再往回查。
而第 13 条是**主动去问"同类问题还有多少"**才浮出来的。
怎么做的
临光 那条修完之后,我没有停在那里,而是问了一句:
临光是"同一个名字指向两个干员"。这种还有几个?
然后写了一段普查:遍历全部 387 个干员 id,逐个检查
_resolve_memory_key(id) 解析到的名字是不是属于它自己。
结果出来 34 个"看起来错"的,逐条判过之后:
32 个是「规范名不在数据里 → 退让到同一人的另一个名字」 ✅ 正确行为
2 个是「解析到**别人**」 ❌ 真错
weiyi / Windflit → W
根因是同一个形状的第三次
`_resolve_memory_key` 的包名退让:
if low in name.lower() or name.lower() in low:
^^^^^^^^^^^^^^^^^^^ 这个方向
"w" in "weiyi" 为真 → 任何单字名(W/A)都会匹配进任何包含它的长 id。
⚠️ 而这个形状在本轮出现了三次:
"aak" in "阿梅利亚"(索引构建)、name in k or k in name(build_story_index)、
以及这一处。每次我都以为修完了。
记下来的是方法,不是这一个 bug
修完一个"同类"缺陷之后,要问一句"这种还有几个", 并且把答案写成一个遍历全量的测试 —— 而不是再等下一个坏例子。
⚠️ 而且普查的结果必须逐条判:这次 34 个里 32 个是正确的。 如果不判就"修",会把这 32 个正常退让一起砍掉 (它们对应"规范名不在剧情数据里、但履历还在"的 32 个干员)。 "看起来不对"和"确实不对"之间隔着一遍逐条核对。
现在这条有了总闸
test_no_operator_id_resolves_to_another_operator ——
遍历全部干员,任一个解析到不属于它的名字就判红。
做了变异验证:把反向条件塞回去 → 3 条测试判红;还原 → 全绿。
(⚠️ 解析到 None 是可接受的,对应"她没有这段记忆" ——
那比"她讲别人的故事"好得多。)
后续:那 32 个"规范名不在数据里"的,查清了
普查里 32 个"规范名不在数据里 → 退让到另一个名字"的,当时判为"正确行为", 但没查清那是设定还是数据缺口。查了:
Gnosis .char=灵知 剧情用名=诺希斯 (`.char` 里 4 次 / 2 次)
penance .char=斥罪 剧情用名=拉维妮娅 (`.char` 里 3 次 / **0 次**)
Lucilla .char=海霓 剧情用名=卢契拉
Lumen .char=流明 剧情用名=乔迪
⚠️ .char 的规范标题是游戏内代号(干员名),而剧情里用的是本名。
⚠️ 而 penance 的本名 拉维妮娅 在 .char 里一次都没出现 ——
所以"两个名字都在 .char 里、只是没连起来"这个假设不完全成立。
退让必须留着(不能改成"只在两个名字都出现时才退让")。
判据:数据给那个本名的 operator_ids 只有这一个 id ——
即数据自己认定"这个名字就是这个干员",不是不同的人。
生产实测:
| 问 | 答 |
|---|---|
| 灵知 × 你是谢拉格人吗 | 「谢拉格,埃德怀斯家——世代替谢拉格保管卷宗的那一支。」 |
| 斥罪 × 你以前是做什么的 | 「叙拉古城邦法院,法官。判过很多人——大多数有罪。」 |
⚠️ 它看起来就像"解析到了另一个名字" —— 所以加了 2 条测试专门锁住它, 并在代码里留了注释,免得后来者把这 32 个当 bug 一起"修"掉:
test_codename_falls_back_to_the_story_name
test_fallback_names_belong_to_the_same_operator
"看起来像 bug 的正确行为"和"真的 bug"需要不同的处理: 后者要修,前者要加测试防别人修。