罗德岛通讯与技术部

剧情检索接线(Phase 5)

STORY RETRIEVAL

本轮共 13 个缺陷,全部表现为「她不知道」或「她答对了但不该那么答」,

状态:🟢 三条检索线全部上线并验证 · 最后核对 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 大小写(specterSpecter §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=1n=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+ (同序列内可靠)→ 密录/番外。"看起来精确的错序"比"少给点"坏得多。

⚠️ 而且第一版有两个排序 bugMINI_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.py2009/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 namebuild_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"需要不同的处理: 后者要修,前者要加测试防别人修。