罗德岛通讯与技术部

剧情方向

STORY DIRECTION

干员格 391 个 .char · 390 个唯一标题 · 注册表 389 个 id

状态:🟢 三条检索线全部上线并验证 · 最后核对 2026-09-12 读者定位:想知道"这个方向做完了什么、现在能回答什么、还差什么"看本文。 想知道踩过的坑剧情记忆接线-验证与踩坑

数据快照(2026-09-12)

干员格      391 个 .char · 390 个唯一标题 · 注册表 389 个 id
            ⚠️ 唯一重名:`海霓`(`haini.char` 残桩 + `Lucilla.char` 完整版)
剧情记忆     18,663 事件 → 429 键 / 23,447 条第一人称经历
关系(干员)  30,180 条 → 归并 14,283 条 / 358 键
关系(全体)  30,180 条 → 31,300 条 / 4,795 键   ← §11
时间线      480 个事件分区
剧情人物     4 个(霜星 · 菈玛莲 · 裘里奥 · 吉阿达)· 各带头像   ← §12
干员关系网   三源合成(剧情 + 静态图谱 + 博士)· 接口已就绪     ← §14
本名       222 / 391(56.8%)· 缺口 169 个**全部有解释**(§7)

⚠️ 数字每次都要重新数。本文写"387 个 .char / 204 个本名"那版, 一周内就被后续提交推到了 391 / 222 —— 引用过期数字比不写数字更坏。


0. 起点:三条要求

达成一个干员被问起自己在剧情中的记忆时可以准确回复, 并且能明白自己与博士、自己在社会中的人际关系和处境, 还有剧情的时间线

三条要求 → 三条检索线。


1. 三条线是什么

① 剧情记忆 ② 人际关系 ③ 剧情时间线
回答 发生了什么 她与谁有什么 按什么顺序
数据 2009 剧本 → 18,663 事件 → 429 键 / 23,447 条第一人称经历 2009 剧本 → 30,180 条关系 → 归并 14,283 条 / 358 键(全体 31,300 条 / 4,795 键,见 §11) 480 个事件分区
入口 recall() / build_block() relations_for() / build_relations_block() timeline_of() / build_timeline_block()
判据 事件词 + 在场人 用户提到了谁 有经历的事件
无命中 不注入 没人被提到则不注入 少于 2 个事件则不注入
覆盖 429 键(干员)+ 4 个剧情人物 ⚠️ 运行时只有 358 键;4,795 键的全量索引未接线(§11) 480 分区全覆盖

三块接在同一处api/chat.pyapi/deep_chat.py),共用一份实现 —— 没有第二个副本("同一个概念两份实现"是这个仓库最稳定的失败形状)。

她实际在 prompt 里看到的:

【你自己的亲身经历——这是**事实**,不是参考资料】:
【你和这些人的关系——从你经历过的剧情里整理出来的】:
【你经历过的剧情,按你记得的先后】:

2. 能达到什么效果(生产实测)

关系

左乐 × 你和惊蛰 远亲,我唤她一声小姨——不是正经亲戚,只是父亲托她照看我,她便真拿长辈的架子管教起我来了。」
× 你和魏彦吾 他是我舅舅,也是教我剑术和法术的人。曾经是我的上级——我已经辞职了。」
临光 × 你和玛嘉烈 玛嘉烈就是我——玛嘉烈·临光,临光是家族的姓。你问这个,是想确认什么?」
玛恩纳 × 你和玛嘉烈 叔侄。她叫我叔叔——虽然她有十年没这么叫过了。」
迷迭香 × 你和煌 「煌——她是精英干员。也……是家人。她说小猫咪长大了。」

⚠️ 左乐那条把注入的三条关系全部复现了(远亲/小姨/不是正经亲戚 · 父亲托她照看 · 拿长辈架子管教),而且是融进她自己的语气里说的。

记忆

重岳 × 你和黍、望 「望那孩子……我曾隔着梦境让他活着回来,他连一句保证都不肯给我。」
阿米娅 × 你还记得最早的时候 雷姆必拓的那一年吧——从车队残骸里被你抱出来,跟着你和暴行走了整整一年。」

⚠️ 重岳那条几乎逐字复现召回的 memory (「我隔着梦境与现实的界限让望看见我怀中的造物……我让他活下去、活着回来, 他说不会向我保证什么」)—— 这是"有据"的最强证据形态

时间线

玛恩纳 × 你先后经历 「……临光小姐参加骑士竞技那阵,我拦下过玛莉娅——就这些,记着而已。」
临光(Nearl) × 你先后经历 「卡西米尔之后的事说来话长——黑暗时代那几场我去接过阿米娅和Ace,乌萨斯荒野里布置过抽压机和风仓……」

⚠️ 这两条是对照组:修 同名不同人 之前,玛恩纳答的是 「黑暗时代那两场,苦难摇篮,怒号光明」—— 那是 Nearl 的戏份

边界

阿米娅 × 「今天天气不错」→「连走廊里都亮堂起来了。博士,窗边那个位置现在晒得到太阳——要过来坐一会儿吗?」

没有突然讲起切尔诺伯格。


3. 一句话概括

她现在讲的剧情,逐词能在 2009 个剧本的原文里找到出处 —— 而不是从模型自己的游戏知识里编的。

这也是这一轮反复验证的唯一判据:把回答里的关键说法拿回数据里逐词查


4. ⚠️ 达不到什么(必须说清的两条)

① 「有据」与「看起来有据」分开了,但「让她不编」只做成一半

实测两个失败例子,她仍然说「走廊上打过几次照面」,而数据里同场 0 条

情况 修之后
有据(夕/令,覆盖完整) 「我妹妹呗……一个把自己关进画里不肯出来的」
虚假有据n=1 但命中的与问题无关) 「谁?临光家那位叔叔?……不就走廊上打过几次照面 仍编
无据n=0 「……见过,没聊过几句 仍编

变化是:从"编出具体的错误亲属关系"变成"含糊搪塞"。但那仍然是编的。

现在的处理是不削、当监控信号recall_diag()n=0 / partial=1scripts/analyze_story_injection.py 统计)。⚠️ 削它会一起削掉 「重岳 × 黍、望」那种"部分覆盖但答得极好"的情况。

② 抽取本身有错,而错的那条读起来很正常

用外部资料核对时发现一例:佐菲娅是临光的姑母, 而我们的数据里 临光 ↔ 佐菲娅 没有对应条目(玛恩纳那里标的是"同事/同族")。

⚠️ 这类错没有任何信号 —— 它不报错、不空、措辞也正常。 只有拿外部资料或原文逐条核对才会露出来。


5. 工程上留下的东西

可观测性(这一轮最重要的产出)

recall_diag()      n / hit_words / persons_asked / persons_covered / partial
chat.py 日志       [剧情记忆] op=年 n=1 块=378 时间线=639 关系=297 部分=1 问及=['玛恩纳'] 覆盖=[]
analyze_story_injection.py   partial=1 的统计工具

⚠️ 加这些的直接原因:在那之前"块到底进没进 prompt"只能靠回答像不像去猜, 而那个判据和"模型不听指令"分不开 —— 实测就卡在那儿好几轮。

回归集

tests/eval/story_recall.json + tests/test_story_regression.py —— 56 条(55 正例 + 1 负例),其中约一半是用外部资料交叉核对过的

⚠️ 断言的是检索层,不是模型的回答(LLM 输出不可复现:同一剧本跑三次得 19/28/27)。 ⚠️ must_any 只放能在数据里逐词找到的东西 —— 「夕是十一妹」听起来对但原文里没有,那种词进回归集就是为"编得对"背书

它建成当天就抓到一个真缺陷(负例 今天天气不错 判红 → 关系块被无差别注入)。

⚠️ 外部交叉核对:48/49 命中,但发现一类系统性抽取缺陷

arknights_lore_wiki (LLM 阅读原文生成的机构化角色页)当外部真值,跑了 49 条关系对照

48 条命中(我们的数据里有对应边)
 1 条"看起来不符"(玛恩纳↔切斯柏 —— 查下去发现**我们有 4 条,比 wiki 的单线描述更全**)

⚠️ 但核出一条系统性缺陷

我们:临光 ↔ 莱姆 = `叔侄 (reported)`
原文:玛嘉烈: 「是的,**莱姆叔叔**,我们又见面了。」
真相:**「叔叔」是尊称**(对长辈骑士的称呼),**不是亲属**

同类:太合(左乐称叔,但有上下级意味)。

⚠️ 中文的「叔/伯/姨/姑/舅」既作亲属称谓也作尊称, 而抽取分不开这两者 —— 于是大量非亲属会被标成亲属

⚠️ 这类错的没有信号:不报错、不空、措辞正常。 只有拿外部资料或原文逐条核对才会露出来 —— 这就是为什么回归集里 那条只断言"查得到"、不断言"叔侄"

测试

story 相关 117 条(回归集 58 + 经历 27 + 名字解析 32),生产上也跑。

全量证据审计 scripts/audit_relation_evidence.py

拿每条边自带的 line_no 回原文核对 —— 不依赖任何外部来源,所以能跑全量。

⚠️ 但它量出来的不是错误率,这条要记清(第一版判据就是错的):

数字 是什么
"两方都不在场" 52.9% → 36.6% ⚠️ 第一版只看证据那一行;对话里用"你",名字在上一行。改成 ±3 行(与抽取脚本自己的在场校验一致)后是 36.6%。我的判据比被审对象还严。
"证据窗口内无亲属词" 43.1% ⚠️ 抽样 12 条人工核,没有一条是"关系编造" —— 依据在别的行/别的剧本、谓词用词不当、或 note 自己承认推断
note 里自认推断/含糊的 这个才是可测的:全边 8.4%(2541/30180)· 亲属边 20.9%(710/3397)

⚠️ 模型不是在编,它在标注不确定 —— 样本里甚至有 可萝尔 --远亲--> 村民 | 可能为远亲或**邻里尊称**(它自己就注意到了尊称问题)。

报错率之前,先确认判据能不能测到那件事,以及判据的严格程度是否和被测对象一致。


6. 已知的遗留

说明
partial=1 的注入策略 ⏸ 先不削,等监控数据
尊称被当成亲属 prompt 已修(第 7.5 条)并在问题剧本上验证。⏸ 2026-09-12 决定:暂不重跑全量(~¥19 / 几小时)—— 现有产物仍是旧 prompt 的,知道这件事就行,别以为已生效
谓词用词不当 艾沃娜 --监护--> 正义骑士号 的 note 自写"主人与宠物" —— 监护 不等于主宠。这类没修(还没量过有多少)
关系图里本名/代号节点分裂 已修(73 → 0,见 §10):同一个人的两个名字(哀珐尼尔/Logos)曾是两个节点,于是母子关系被拆开。判据统一后才好——修这一步我改了四次,每次只对一半
她的本名 已修(见 §7):PRTS 权威源补了 222 个 .char本名:(另有 曾用/假名,有才写)。缺口 169 个已逐条查清(167 根本不在 PRTS 页面上 · 1 lost · 1 是重名残桩),真缺口 0
🔴 全量 NPC 关系索引是死产物 ⚠️ 见 §11:data/npc_relations_index.json(4,795 键 / 31,300 边)已建但没有任何运行时读它 —— 塔露拉的关系现在依然查不到。⚠️ 且实测不能整个替换干员索引(阿米娅 400 → 378),正确接法是分层补集(先查干员索引,查不到才落 NPC 索引)
异格(ALTER)完全没实现 见 §8 —— .char / manifest / operator-config / 图谱四处都没有变体概念
霜星的结局与已记录内容冲突 见 §12:.char 写"从源石里解压出来",而剧情数据记的是火化 / 阵亡名单。三条路(改写 / 分层 / if 线)未选
图谱里的「同族」 1959 条边是分类标签不是人物关系;临光家族在图谱里真关系 0 条
关系网(给用户看的那层) 🟡 接口已就绪network.py + GET /api/character/{id}/network,20 条测试)UI 待做。⚠️ 静态图谱单独不够用:2163 条边里 90.6% 是「同族」,去掉后 228/302 个节点没有真关系边(§14)
🔴 graph._build_doctor_edge() 的陌生人守卫是坏的 ⚠️ 见 §14/backlog D9:守卫判 overall <= 0,而缺省信赖是 25neutral)→ 每个干员都有博士边,389 个人全都和博士有"信赖关系"。关系网这条路径已兜住,graph.py 本身没改
🔴 剧情索引没吃到别名表 ⚠️ 见 §14/backlog D10:哀珐尼尔 -> 逻各斯 在表里,而产物里两人各占一条(同一对母子是两个人)。读取侧已兜住,产物本身待重跑
32 个"规范名不在数据里" ✅ 已查清是代号 vs 本名(设计如此),有 2 条测试锁住
1 个干员取不到关系 摆渡人 —— 剧情里真没有边
⚠️ 唯一重名标题 海霓haini.char 残桩 + Lucilla.char 完整版)。没修 —— 修它要决定哪个 id 是正主
渲染想用 display 但索引里没有 recall.py 有未提交改动要按 display 渲染(键用于查找、display 用于说话),而两份索引里都没有 display 字段 → 目前 fallback 到 other,是空操作

7. 她的本名 —— ✅ 已修(222 个 · 缺口 169 个全部有解释)

问题

剧情数据按人名索引,而 .char 的标题是代号,prompt 只给 .char —— 于是她被问"你叫什么"时答不出本名,而她的剧情经历全在本名名下

数据源:PRTS「角色真名」

https://prts.wiki/w/角色真名 —— 玩家共同维护的中文 Wiki,专门记载 "在鹰角网络所发布正式内容中的写法",每条带出处。 PRTS 页面上共 227 行,其中 222 行落到了我们的 .char 里。

⚠️ 我先前试过两个源,都错了

ZOOT 的 name 字段      给的是**早期名/未使用名**
                       例:`冰酿` ZOOT=`海伦娜`,PRTS=`玛德琳`(现用)
从剧情语料里"取同 id 的其它名字"  混进了**昵称/称号**:
                       `小个子`(格拉尼)· `圣徒`/`酒神`(送葬人/傀影)·
                       `通讯兵`(极境)· `工程师`(森蚺)· `鬼姐`(星熊)

区分"本名"和"昵称/称号"需要外部权威,不能从语料里猜。

⚠️ 五个名字标记必须分清(都踩过)

→         更迭顺序(末位是现用名)
fake      **假名**
del       **已弃用**
langs     有非中文写法(**不改变它是不是真名**)
<br>      **并列**(艺名),**不是更迭**

和弦   洛西莉 <br>→<span class="fake">阿赫茉妮</span>
       ⚠️ 我按"末位=现用"取 → real 和 pseudo 都成了 `阿赫茉妮`(**自相矛盾**)
       ✅ 正解 real=洛西莉 · pseudo=阿赫茉妮
遥     紫野遥<br>羽生萌萌香
       ⚠️ 按 `→` 切不到 `<br>` → 粘成 `紫野遥羽生萌萌香`(**不存在的名字**)
冰酿   安娜·米特朗 → 海伦娜 → 玛德琳    现用 = 玛德琳
锏     (真名遗失)→ **不写**:写个已知是"没有"的名字比不写更坏

⚠️ 幂等 = 覆盖,不是"缺了才补"

第一版补丁只在字段不存在时插入 → 上一轮写错的陈旧值留下了: 修正后 本名:洛西莉 与旧残留 曾用:洛西莉 同时存在(自相矛盾)。 所以有 scripts/normalize_real_names.py先清空那四个字段,再重建。 跑多少次结果一样。现在 222 个 · 0 重复 · 0 自相矛盾

生产实测(4 例全对)

斥罪 × 你的本名 拉维妮娅·法尔科内。叙拉古的法庭档案上就是这么登记的。」
冰酿 × 以前叫什么 「**安娜·米特朗。玛德琳。**都不太用了。名字这东西,用久了会旧。」
和弦 × 你到底叫什么 「这要看你想认识的是哪一个——档案上的、维多利亚军部的,还是坐在你面前这个?」
W × 你的真名 维什戴尔——这么珍贵的名字,你倒是问得挺随意。」

⚠️ 冰酿那条用对了曾用/现用的区别;和弦那条拿捏了假名与真名的分寸 —— 说明不只是"字段被读到",而是模型用对了语义

仍未覆盖:169 个 —— ⚠️ 全部有解释,不是"漏了 169 个"

391 个 .char
├─ 222  有 本名:
└─ 169  没有
   ├─ 167  ✅ PRTS「角色真名」页面上**根本没有这个人**
   ├─   1  ✅ `锏` —— PRTS 自己标 **真名遗失**(写个已知是"没有"的名字更坏)
   └─   1  ✅ `海霓` —— ⚠️ 唯一重名标题:`haini.char` 是 1461 字的**残桩**,
                      真身在 `Lucilla.char`(2858 字,**有本名:卢契拉**)。
                      两个 id 指向同一个人 → **不是缺名字,是重复**

⚠️ 但 海霓 这件事比"缺个本名"严重得多 —— 见下。

⚠️ 海霓 真正的病因:没人用的那个才是好文件

manifest / 前端 operator-config.js / 头像   都指向 `haini`      ← 用户实际对话的就是它
`haini.char`     1461 字 · 打磨循环产物 · 无本名
`Lucilla.char`   2858 字 · **PRTS 管线重写** · 有本名 · 有完整锚段

实测加载路径:get_operator('haini').prompt_pathhaini.char。 也就是说博士现在跟海霓对话,用的是那份更薄、更旧、没本名的卡, 而 PRTS 管线重写过的那份从来没有进过 prompt

⚠️ 这跟 §10 那条是同一种形状产物存在 ≠ 功能存在。 只不过 §11 是"没人读",这里是"读的是另一份"。 而它对用户的表现只是"海霓好像没那么丰富" —— 不报错、不空、查不出来。

⚠️ 修它要先定哪个 id 是正主haini 有三处引用、Lucilla 只有文件), 不是简单删一个 —— 没做

⚠️ 我第一版把 169 个说成"PRTS 上没有",没分类就报了总数 —— 而这两件事完全不同:"PRTS 没有"是数据源的边界,"该写没写"是我们的缺陷。 按上面的判据重新逐个核过之后,真缺口 0 个

⚠️ 不猜 —— 没有可靠来源的就空着。 scripts/normalize_real_names.py 可反复重跑,结果幂等。 核数脚本见 .tmp/name_stats.py(一次跑完上面全部计数)。


8. ⚠️ 异格(ALTER)—— 四处都没有这个概念

现状(查过四处)

位置 有没有变体概念
app/prompts/operators/*.char ❌ 391 个文件 / 390 个唯一标题(海霓 一处重名,见 §7)
app/systems/operator/operator_manifest.json ❌ 只有 display_name / avatar
frontend/js/operator-config.js ❌ 只有 name / avatar / bio / faction / org
relation_graph.json ❌ 302 节点,0 个异格

所以现在没有异格。 想加要先定"异格是什么"。

⚠️ 一个必须先定的问题:异格是同一个人的另一形态,还是另一个人

这决定整个实现,而剧情数据里两种都有

`陈`     ids=['chen']       同 id 别的名字: ['陈晖洁']     ← 本名,不是异格
`银灰`   ids=['SilverAsh']  同 id 别的名字: ['恩希欧迪斯']  ← 本名
`能天使` ids=['exusiai']    同 id 别的名字: ['蕾缪乐']      ← 本名

⚠️ 而 魔王 一度被我以为"是阿米娅的异格",查下去是另一个人 (萨卡兹的魔王,在 15-7 里指导阿米娅 —— 我们数据里的边 阿米娅 --合作--> 魔王 | 魔王引导和帮助阿米娅 (direct) 是对的)。

先分清「本名」「称号」「另一形态」「另一个人」这四件事,再动手。 我这轮就误判过一次(把 魔王 当异格)。

三种可选实现(按侵入性排)

① 只加名字映射(最小) .char## 锚 里写一行 其它称呼:魔王。 影响:她知道自己也叫这个;剧情记忆按任一名字都能查到(召回层已支持,见 §1)。 ⚠️ 缺点:分不清"现在是什么形态" —— 两个形态的记忆会混在一起。

② 加 form 字段(中等) .char形态:灰烬(默认)/ 灼燃,召回时优先当前形态的记忆。 影响:能回答"你现在是什么样子";两形态的记忆可分辨。 ⚠️ 要做"当前形态"的状态管理(.char## 我 段本来就在做时态化, 可以挂在那儿)。

③ 独立干员(最大) 把异格做成独立 .char + 独立 manifest 条目(像游戏里那样)。 ⚠️ 那会重复大量设定,而且"她们是同一个人"这件事就没地方表达了 —— 这与本项目"单一数据源"的做法相反

⚠️ 我不建议现在就做,理由是缺一样东西

做异格需要"剧情数据里怎么区分两个形态" —— 而现在的抽取产物 没有形态字段。所以:

· 若走 ①,现在就能做(只加名字,不需要重抽)
· 若走 ②/③,**要先重抽关系/事件**(带形态标注)—— 那是 ~¥19 那一档

建议顺序:先做 §7(本名,便宜且是明确的缺口)→ 再定异格走哪条路。


9. 与其它方向的关系

本方向(story)      她从**剧情原文**里记住的东西   —— 正典级、带行号证据
L3(facts)          她从**与博士的对话**里记住的   —— 按用户隔离
静态图谱             干员 ↔ 干员,302 节点          —— **设定级**
world_note           世界观常识                    —— 与上面都不重叠

⚠️ 故事关系不并进静态图谱:键空间不同(图谱按 operator_id, 剧情人物是无 id 中文名)、粒度不同(干员级 vs 单章事件级)。 实测 ST-1 抽出的 9 个人名里只有 1 个是图谱节点。


10. ⚠️ 本名/代号节点分裂(73 → 0)

症状

关系图里同一个人是两个节点

哀珐尼尔   作为"对方"出现 ✅   作为"主语" ❌   ← 她自己那份关系不存在
逻各斯     作为"主语" ✅

于是母子关系被拆开菈玛莲 --近亲--> 哀珐尼尔Logos --近亲--> 菈玛莲 无法合并 —— 而那是同一条关系

实测真分裂 73 个(判据:两个名字是图里的节点):

哀珐尼尔 ⟷ 逻各斯 · 玛嘉烈·临光 ⟷ 临光 · 陈晖洁 ⟷ 陈
阿梅利亚 ⟷ 时隙 · 塔妮 ⟷ 珊比 · 海伦娜/玛德琳 ⟷ 冰酿

⚠️ 修它我改了四次,每次只对一半

尝试 目标 为什么又错了
PRTS 中文名(逻各斯 主语侧解析出的是 Logos → 换个位置继续分裂
.char 标题(万顷 主语侧解析出的是 禾生经历数据按 禾生
主语侧也过表 检测器判据错了:把"已收敛"当成了"分裂"
_resolve_memory_key(干员, canonical_only=True) + 两侧都过表

判据不统一时,每次修正都只是把分裂挪一个位置。 两侧用同一张表才叫一致。

⚠️ 还有一层:检测器的判据也要对。前三次我用"本名出现在图里"判断, 而那会把"只有本名一个节点"(= 已收敛,正确)也算成分裂。 正确判据是「两个名字都作为节点存在」。

产物

· data/name_normalization.json(589 条 · 0 冲突) · build_relations_index.pykother 两侧都过同一张表

验证

真分裂: 0(原 73)
菈玛莲 ↔ Logos 现在 4 条,含「母子关系」「菈玛莲是前女妖之主,哀珐尼尔是现任」
测试 117 通过(无回归)

11. ⚠️ 全量 NPC 关系索引 —— 数据已建,但还没接线

做了什么

现有的 story_relations_index.json 只覆盖.char 的干员(358 键)。 剧情里大量人物不是干员(塔露拉 · 霜星 · 梅菲斯特 · 爱国者……), 他们的关系原本无处安放 —— 于是"博士问起塔露拉和谁有什么关系"查不到。

scripts/build_npc_relations.py 把索引扩到全部有边的实体

data/npc_relations_index.json   4,795 键 · 31,300 条边 · 9.8 MB
(脚本头注释写"5062 个实体" = 过滤前的实体数;过滤掉 `同族`/`other` 后剩 4,795 键有边)

与干员索引同源同判据_key()_resolve_memory_key(canonical_only=True) 再过 data/name_normalization.json_better() 同款归并,SKIP_PREDICATES={"同族","other"}

⚠️ 但是:它现在不生效

引用 `npc_relations_index` 的地方        只有 `scripts/build_npc_relations.py` 自己
`recall.py` 实际加载的数据文件
    MEM_PATH  story_memories.json
    TL_PATH   story_timeline.json
    SC_PATH   story_characters_index.json     ← 4 个剧情人物,§12
    REL_PATH  story_relations_index.json      ← 干员索引(358 键)

没有任何运行时读它。 所以那 31,300 条边只存在于磁盘上, 博士问塔露拉的关系依然是查不到的

⚠️ 我原来那个 commit 说「塔露拉等现在有自己那份」—— 只对数据文件成立,对运行时是假的。已经在这里改口: 产物存在 ≠ 功能存在,"我生成了它"和"它被用上了"是两件事, 而 commit message 很容易把前者说成后者。

接线要动什么(未做)

recall.py_load() 现在已经会在加载时把 SC_PATH 并进 _CACHE["subset"] (剧情人物走这条路),所以接线点已经现成

⚠️ 但实测出一个必须先解决的事实两个索引对同一个人的条数不一样

索引共有的键:358 / 358(干员索引**完全是** NPC 索引的子集)
⚠️ 而共有键的条数,NPC 索引**更少**:
    阿米娅    干员 400  →  NPC 378
    W         干员 129  →  NPC 113
    弑君者    干员  76  →  NPC  68
    霜星      干员  63  →  NPC  62
NPC 独有(现在完全查不到的):
    塔露拉 158 · 爱国者 41 · 梅菲斯特 38 · 浮士德 21

⚠️ 所以不能拿 NPC 索引整个替换干员索引 —— 那会让每个干员的 关系变少SKIP_PREDICATES={"同族","other"} 过得更狠,判据也不同)。 正确接法是"补集"不是"替换"

· 干员索引先查 → 有就用它(保住现在已调好的排序/上限/`{}``[]` 标记)
· 没有才落到 NPC 索引(塔露拉这类才有结果)
· ⚠️ 这正是我原来卡住的"键空间冲突",答案比想的简单:**不需要合并,只需要分层**

⚠️ 代价是多留一份 9.8 MB 索引在内存里。没做 —— 因为它是 "锦上添花"(塔露拉能查到)而不是"修缺陷"(干员本来就查得到), 而接线要走一遍 _load() + 排序 + 测试,这一轮没排上


12. 四个可对话的剧情人物(非干员)· 各带头像

是什么

.char 原来只给干员。而用户想对话的有些人根本不是干员 (霜星不是可获取干员)。于是加了一层「剧情人物」:

data/story_characters.json         [{id, name, enabled}]  ← 唯一的登记处
scripts/export_story_characters.py 由它生成 ↓
data/story_characters_index.json   她们的经历 + 自己那份关系(157 KB)
recall.py: SC_PATH                 _load() 时并进 _CACHE["subset"]

驱动方式是"名单文件",不是散在各处的判断 —— 加人只改 story_characters.json + 丢一个 .char + 一张头像。

现在有 4 个

id 名字 头像
frostnova 霜星 frontend/assets/avatars/operators/frostnova.webp
ramale 菈玛莲 ramale.webp
giulio 裘里奥 giulio.webp
giada 吉阿达 giada.webp

⚠️ 她们没有 id 层面的干员身份,但 .char 的文件回退 (见 registry.py 的 file fallback)让"有 .char 就能被对话"成立 —— 不需要改注册表

头像怎么来的 Tools/import_story_char_avatars.py

用户桌面给的图白底(菈玛莲明确是白底),所以要去背 + 裁头

· 去背:亮度 > 240 的像素 alpha 置 0 —— 白底不是"抠图",是**阈值**
· 裁头:**宽度剖面法**找头(头像应该是头部,不是整张立绘)
· 输出:160×160 WebP q82

⚠️ 阈值法只对纯白底有效。非白底图会留下一圈脏边, 而它在 160×160 下特别明显 —— 换图要重新看图,不能只跑脚本。

内容来源

.char 里的剧情事实来自 story_characters_index.json(从剧本抽的第一人称经历); 外部事实(如霜星的结局)按用户要求另行查证写入。 ⚠️ 但霜星的结局与已记录的「火化/阵亡名单」有冲突,未解决(见 §6)。


13. 泛称职务标 [] —— 让"不是名字的东西"看起来不像名字

症状

抽取会把匿名的人写成称谓,渲染成:

- 罗德岛干员:格拉尼与罗德岛干员有长期合作

⚠️ 这一行读起来像"她认识一个叫『罗德岛干员』的人" —— 而那不是名字。 实测这类 321 种 · 778 条边医疗干员/罗德岛干员/萨卡兹战士……)。

两套标记,因为匿名的方式不一样

亲属称谓  `{姨妈}`     匿名的是「你的一位亲属」   —— 实测 132 条
泛称职务  `[罗德岛干员]` 匿名的是「那个组织里的某个人」

渲染时加标记(recall.py_is_unnamed_kinship / _is_group_label), 同时在 prompt 里解释这两种括号 ("带 {} 的不是名字……带 [] 的也不是名字……不要自己补")。

⚠️ 判据是形态学的(看词尾),不是"在不在名单里"

_GROUP_TAIL = (干员|成员|战士|卫兵|…|军|队|们)$
_BARE_ROLE  = {医生, 法官, 老师, 船长, 骑士, …}

⚠️ 我先试过用名单当判据,错了两次

① 名单太小时  → 把 `塔露拉`/`Ace` 这种**真人名**也算了进来(6372 条)
② 名单太大时  → `医疗干员` **在离线全量里有条目**(它是匿名角色但有台词),
                于是被排除 —— 而它恰恰是**最典型的泛称**

「在名单里」和「是专名」不是一回事。 前者是数据事实,后者是语言形态,用前者去判后者必然两头都错。


14. 干员关系网(给用户看的那一层)—— 🟡 接口已就绪,UI 待做

是什么

app/systems/relation/network.py + GET /api/character/{id}/network。 把三个来源合成一个干员的一跳邻域:

story   剧情关系(2009 剧本抽的,带活动名与逐行证据)  ← 用户点名"这个很好"
static  静态图谱(`relation_graph.json`)
doctor  **当前用户**与她聊出来的(信赖 / 频率 / 深夜常聊)

⚠️ 三源的键空间不一样,这是本层存在的第一个理由

图谱键   id,但**大小写混乱**(`Bubble` / `Siege` / `narantuya` 混着)
剧情键   **中文显示名**(`阿米娅`),⚠️ 与图谱的 **id 键交集只有 3/358**
博士键   id(小写)

所以"点一个邻居跳过去"必须先归一化到前端能查的 id。实测图谱 302 个节点里 301 个能用 id.lower() 命中前端 operator-config.js

⚠️ 为什么静态图谱单独不够用(这是最硬的一组数字)

relation_graph.json        302 节点 / 2163 边
⚠️ 其中「同族」             1959 条 = **90.6%**
真关系边                    204 条 = 9.4%
────────────────────────────────────────
去掉「同族」后仍有边的节点    79 / 302
⚠️ 只有同族边的              213 个
⚠️ 连一条边都没有的           15 个(含 `推进之王`/`蛇屠箱`)
→ **随机点开一个干员主页,约 3/4 概率关系网是空的**
每节点边数中位数 **2**(只有 1 条边的有 33 个)

一个好消息graph.get_related() 的默认行为已经是"不含同族" (2026-09-11 修的,注释里写了动机:「阿米娅的 18 个'关系'里 11 个只是同为卡特斯」)。 所以内核已经站在正确的一边,本层借它的 _is_meaningful(),不重写

三条过滤规则(都是判据,不是口味)

排除 判据 为什么
「同族」 graph._is_meaningful() 它是 race 属性派生的分类标签,不是剧情关系
非人 图里唯一没有 race 字段的节点 = prts "罗德岛终端 AI"不该作为对等节点出现在干员关系网上
同一人分裂 priestess_unmasked 它是 priestess 的第二个节点,且前端查不到 → 点了没反应

⚠️ 第 2、3 条的测试里有一条反空转断言:这两个 id 必须真的还在图里 —— "排除一个不存在的东西"也能让测试通过,那种测试什么都锁不住。

四件在做的过程中撞出来的事

① 博士边:守卫是坏的,我们兜住了(→ backlog D9)

graph._build_doctor_edge() 的守卫是

if trust["overall"] <= 0 and trust["overall_level_key"] == "stranger":
    return None

trust_manager.get_trust()从没聊过的干员返回 overall=25.0 / neutral (缺省值就是 25,不是 0)—— 两个条件一个都不成立。 于是 _doctor_edges 会给每个干员建边,网络视图会给 389 个人都挂上 「她与你 · 信赖关系」。⚠️ 而它看起来完全正常:数字真实、档位合理。

✅ 本层改判 last_interaction_time(与 trust_manager.apply_decay_if_idle() 同口径),实测 amiya 有互动记录所以有博士边、ramale/zuole 没有。 ⬜ graph.py 本身没改 —— 它还有别的调用方,改共享行为要有自己的验证。

② 别名没合并:同一对母子是两个人(→ backlog D10)

name_normalization.json 里明明有 哀珐尼尔 -> 逻各斯,而产物索引里 菈玛莲 名下两者各占一条哀珐尼尔 5 条 + Logos 1 条,note 都是母子关系)。

✅ 读取侧过表之后:菈玛莲 9 → 8 条,并成一行 Logos(哀珐尼尔), 谓词攒成 监护 / other / 近亲 / 上下级。 ⚠️ 合并时谓词必须攒住:只留一条会丢掉"前女妖之主与现任"那个说法。

⚠️ 判据提醒不要用"note 相同"判同一人。 实测在一个干员上就有 613 处误报 —— 临光 下的 夜莺闪灵 note 都是"战友", 而他们是两个人。只认人工核过的那张别名表。

③ 我的测试自己有错(两次)

`"AMİYA".lower()`     用了土耳其点号 `İ` → 得到 `i̇`(两个码点),
                      **那是个假输入**,和 `amiya` 本来就不是同一个字符串
源码守卫              `inspect.getsource` 全文搜 `trust["overall"]`,
                      而 **docstring 里引用了那句坏判据**(为了说明它为什么坏)
                      → 守卫把解释文字判成了违规代码
                      (同 backlog A4:**盯行字面而不是盯语义,就会误判**)

海霓 又是它

_name_to_id() 撞到"两个 id 同一个名字"—— 就是 §7 记的 haini/Lucilla。 选了 manifest 里登记的那个(haini),并让选择确定: 不稳定的话同一个人每次刷新换一个 id,那种飘忽比选错更难查。

接口形状

GET /api/character/{operator_id}/network
→ {operator_id, name, neighbors[], categories[], stats{}}

neighbors[] 每项:id / name / category / predicate / note / sources[] /
                 basis / count / events[] / navigable /
                 trust_value / trust_level / chat_frequency_7d / tags[]

⚠️ 两个必须显式做的事

· request_username —— 中间件只写 request.state.username, 而博士边按 {username}:{op_id} 隔离。不设的表现是读到 _no_user_ 那一份 (别人的信赖值),而它看起来完全正常。 · 端点用 async def —— build_network() 是同步重活(读索引 + 关系图), 同步端点会被丢进线程池,而 ContextVar 跨线程的行为不值得再赌一次。

⚠️ 空网络不抛 404 —— 实测约 3/4 的干员去掉「同族」后没有真关系边, 抛出去前端会显示"加载失败",而真实情况是"这个人在剧情里没和谁有过关系"。

UI:以她为中心的辐射图("花")✅ 已接线

⚠️ 这一块返工过两次,两次都是我把判据理解错了:

第一次:做成分组列表,理由是 insight.js:100 那条注释—— 而那条反对的是"304 个点两两连线"的社交图,不是"以她为中心的辐射图"。 我把"不要那个"读成了"不要图"。

第二次:列表改成图之后,insight.js 那条顾虑其实仍然成立一半 —— 图上确实有标签挤在一起的问题(见下方"未解决")。

⚠️ 两次返工数据层一条没动(节点/边/归类/别名合并/可跳性/过滤规则), 只换了"怎么画"和"选谁上"。

状态
main.js 视图描述 ✅ 写的就是「信赖三维、情绪曲线和关系网
index.html DOM 区块 #profile-relation-section默认隐藏
CSS .relation-flower 一族,按类别配 cat-*
渲染 JS contacts.jsrenderRelationNetwork() + 纯函数 _flowerSvg()

⚠️ 四条判据(都来自用户定调,不是我的口味)

① 剧情里没提到具体名字的一律省略。 三类,判据各不相同:

泛称          `罗德岛干员`/`人事干员`/`近卫局成员`
              → 借 `recall._is_group_label()`(词尾形态学判据)
无名亲属      `父亲`/`母亲`/`姨妈`
              → 借 `recall._is_unnamed_kinship()`
**无名说话人** `???`(实测 48 处)· `X的声音`(十几个变体)
              → 本模块新加的:`^[??]+$` 或 结尾为「的声音」

⚠️ 不按"含引号"一刀切“灰礼帽”(13 处) · “老天师”(5) · “桥夹”克里夫(4) 是剧情里的绰号,是名字 —— 它们指向一个具体的人。 我一开始想按引号删,那会删掉一批真角色。

② 只要干员 ↔ 干员。 用户原话:「如果不是干员-干员,根本不需要展示」。 判据是 navigable(前端配置里有这个 id = 有页面可跳),不是"有没有 .char"—— 能跳的是页面,页面按前端配置渲染。剧情人物没有主页,画上去只会让人以为点了有反应。

⚠️ 这条砍得很狠,是故意的(实测):

阿米娅   具名 197 → 够格  82
陈       具名 110 → 够格  24
菈玛莲   具名   8 → 够格 **2**(`Logos`/`特蕾西娅` —— 其余 6 个都是剧情人物)
12F      具名   6 → 够格  2

全量关系仍在接口的 neighbors 字段里(未过滤),flower 是它的一个视图。

③ 只有真正值得注意的关系才提出 —— 按被提及次数排,取前 10

⚠️ 刻意不按谓词删:实测阿米娅有 62 个具名邻居带「同事」谓词, 而里面有凯尔希、可露希尔。那条谓词说的是"组织上是什么关系", 不是"重不重要" —— 按谓词删会把她最要紧的人一起删掉

⚠️ 上限 10 不是随手定的,两条实测理由: ① 12 个点每格 30°、10 个正好 36°,手机上更松; ② 它把"同分位的弱关系"自然挡在外面 —— 用户截图点名不要的 太傅cnt=2 / 委托 / 令委托太傅询问某人的情况)正是第 11 位。

⚠️ 同分位时按关系强度排(近亲 > 挚友 > 照顾 > 公务 > 敌对)—— 太傅cnt=2/委托)与 /cnt=2/近亲)同分,纯按 count 排时谁在前是任意的, 于是"问到一次的路人"挤掉了亲人。

⚠️ 不要改成"count < 2 就淘汰":实测那样会让 15% 的干员花全空 (12F 的 博士/凯尔希/阿米娅 全是 cnt=1:它只出现在一个场景里, 所有关系的提及次数都只有 1)。判据必须相对她自己的分布,不能绝对。

④ 「+N」必须显示。 不标出来的话,阿米娅那页会让人以为 她只认识 10 个人 —— 那是图在说谎,不是图不够全。

⚠️ 尺寸:必须按"实际渲染像素"定,不能按 viewBox 单位拍

这是用户截图反馈"看不清人名"的真因,而且是我的算法错:

viewBox 400 宽 · 字号 11   ← 第一版
卡片可用宽只有约 308px     → 缩放 0.77
**字的实际大小 = 11 × 0.77 ≈ 8.5px** —— 中文在 9px 下糊成一片

现在:viewBox 360×320(≈ 容器宽,缩放接近 1)· 字号 13.5 → 实际约 12.7px

配套的四个数必须一起动,而且前后端各一份、必须一致(不一致不报错, 只是点跑到画布外或字被切):

network.py  FLOWER_VB_W/H · FLOWER_R_OUTER · LABEL_PAD · LABEL_MAX_CHARS
contacts.js _FLOWER_VB    · _dotR()        · pad = r + 7 · _clipName()
有测试锁这条:test_viewbox_matches_frontend

⚠️ 中心不写名字(放图下方)

名字原来压在头像下缘。实测两个问题:① 长名字(凯尔希·思衡托 7 字)横着铺开, 压住左右两侧的标签;② 它占了中心那条横带,而标签放不下时正好会翻到内侧。 名字移到图下方就全解了 —— 而且卡片标题本来就有名字

⚠️ 不做内外两环(我试过,更糟)

第一版按提及次数分两环(内 72 / 外 96),想用半径当第二路编码。 实测那让标签更容易撞:内外环的点角度只差 30°、半径差 24 —— 径向间距只剩 24px,而标签高约 15px,一挤就叠 (凯尔希 的花上 迷迭香Logos 就这么叠在一起了)。 强度改由点的大小一路编码就够(点径 5~16,差 3 倍多)。

⚠️ 但点数少时半径要放大_ring_radius):菈玛莲 只有 2 个点, 它们落在正左右,标签放不下就得翻到内侧 —— 翻进来之后压住了中心。 点少没理由收着。实测 2 点 → 132,5~6 点 → 108,10 点 → 96。

⚠️ 坐标算在后端(这一块最重要的设计决定)

network.build_flower() 输出 angle_deg / r / x / y / label_anchor / label_scale

两条理由,都不是洁癖:

· 可测:标签越界这类问题肉眼在某一档屏幕上看不出来(小屏、大字模式才出现)。 算成纯函数之后能在 pytest 里直接断言 —— 「全部 2181 个节点的标签一个都不越界」是算出来的,不是看出来的。 · 确定:同一个干员每次画出来一模一样。 这就是不用 d3 力导向图的根本理由 —— 力导向每次刷新布局都变, 她今天的朋友明天跑到另一个角,用户会以为关系变了

✅ 标签碰撞已修:真实字体度量 + 贪心排布(D13 做完)

改前(真字体量):388 个干员里 203 个至少一对标签重叠。 改后碰撞 0 · 越界 0 · 角度单调

判据的转变是关键 —— 从"先定位置再看撞不撞"改成"先看能不能放再定位置":

旧  ┃ 角度均分(几何)→ 摆上去 → 撞了改锚点/缩字号    ← 事后补救
新  ┃ 按强度顺序逐个 → 每个点在候选里挑第一个不撞的     ← 约束前置

我为此改过 10 轮参数(双环/单环/锚点翻转/半径放大/字号缩放), 每轮都在优化一个和浏览器真实排版不一致的模型,越调越差。

三条约束(都是踩出来的)

① 角度要"有界全圆均分" —— 第 k 个点必须落在自己的扇区 [k·step, (k+1)·step] 内。 第一版搜整个圆,结果节点挤在右半边凯尔希 的 10 个点落在 [-90°, +95°], 左半边空着)—— 贪心只问"放得下吗",不问"该不该在这"。零碰撞但形态坏了。

② 半径是容量的真正旋钮 —— 周长 2πrr 大一半、每个扇区的弧长也大一半。 只给到 +24 时 175 个干员各丢约 2 个点;给到 +42 明显好转。

_fits() 四个边都要查 —— 第一版只查了左右,于是标签从上下被切掉 (越界 1008 处,全是纵向)。横向想得到、纵向漏掉,因为"标签是横着的"这个直觉。

⚠️ 两条我试了但回退了的(写下来免得下次再试)

补放(把扇区里放不下的借邻居扇区的空隙)—— 两版都更糟

版本 A:每个节点一次试完 (0,1,-1,2,-2) → 13 处碰撞 + 154 个干员角度非单调
版本 B:按索引顺序迭代 + 逐步外扩      → 14 处碰撞 + 154 个干员角度非单调

根因是结构性的:"借邻居扇区"本身就必然制造碰撞 —— 邻居的扇区里已经有一个 放好的点,借来的位置只能挤在它旁边或和它重叠。

逐级降字号重试 —— 降到 0.7 之后仍然放不下(卡住的是扇区宽度,不是字号), 却把 154 个干员的花从 10 个点缩成 8 个,白降一轮。

代价(诚实说)

145 个干员各少画约 2 个点(388 个里)。少画的是排在最后的那个, 可解释("排在最后那位挤不进这一圈"),而且接口的 neighbors 字段有全量 (未过滤)—— 信息没丢,只是没画

⚠️ 这个函数我改了 8 次。教训:先量清"不修的话到底差多少",再决定修不修 —— 我第一版直接动手,两轮反工本来都能省掉(纯扇区贪心实测只丢 2 个节点)。

⚠️ 一条只在 Linux 上暴露的缺陷(已修,但值得单独记)

部署后核数字,发现和本地差一半多:

阿米娅   可跳邻居  本地 82  →  生产 34
菈玛莲   上花      本地  2  →  生产  1

图谱节点名大小写混乱(Bubble / Siege),统一小写后去问注册表 —— 而 registry._resolve_prompt_file()display_name_from_char() 都按字面大小写找文件、没有回落:找 logos.char,而文件叫 Logos.char

⚠️ Windows 文件系统大小写不敏感,所以本地全对、测试全绿; Linux 上直接少一半。修法是 _id_by_lower()(小写 → 文件真实大小写)。

⚠️ 新增的两条测试在 Windows 上抓不住它(大小写不敏感照样通过)—— 所以光看本地绿灯不够,必须在服务器上复验数字

⚠️ 顺带撞出四件数据脏东西(→ backlog D12)

① `博士` 不以 doctor 节点抵达(阿米娅的边落在中文名上)
② 泡泡名下有 `泡泡爸爸` **和** `泡泡的爸爸` 两个条目(同一人两种写法)
③ 玩家占位符被当成名字:`{@nickname}` · `罗德岛的Dr.{@nickname}` · `Dr.{@nickname}`
④ `Miss.Christine` 与 `Ms.Christine` 是同一个干员两种拼写

③ 尤其算"不是名字"那一类 —— 它是模板占位符_PLAYER_TOKEN 那个正则 只处理裸 {@nickname},漏了外面带修饰的)。

测试

tests/test_relation_network.py 54 条,三组:

数据层判据   过滤/别名合并/博士边守卫/谓词优先级
花的布局     **只要干员** · 无名说话人不出现 · 绰号保留 · 上限 10 与 rest ·
            按次数排 + 同分位按强度 · **确定性** · 点在画布内(含半径)·
            **标签一个都不越界**(2181 个节点全查)· 锚点候选 · 单环 · 低提及不空
源码不变量   区块默认隐藏 · 容器类名与 CSS 一致 · 前后端 viewBox 一致 ·
            不可跳节点无钩子 · 事件委托而非逐行绑定 · 竞态守卫 ·
            读失败与空态可分 · JS 的 class 都有 CSS(含 `cat-*` 全类别)·
            用到的 CSS 变量都已定义

⚠️ 两条是踩出来的: · --text-accent-cyan 整个仓库只有我这一处引用、从未定义 —— CSS 变量拼错不报错,颜色只是悄悄落回继承值。 · test_viewbox_matches_frontend —— 前后端 viewBox 不一致不报错, 只是点跑到画布外。这条是唯一能抓住两边漂移的地方。

全量 2057 passed / 28 failed(失败集合与改动前逐条一致,全是既有的前端/资源类)。

⚠️ 未做浏览器实测(本机无可用浏览器,也起不了完整服务 —— 缺 chromadb)。 所有的"看着对不对"判断都来自用 PIL 按真机尺寸渲的预览图, 而它终究不等于浏览器排版 —— 上面的标签碰撞问题正是这个差距的产物。