剧情方向
STORY DIRECTION
状态:🟢 三条检索线全部上线并验证 · 最后核对 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.py 与 api/deep_chat.py),共用一份实现 ——
没有第二个副本("同一个概念两份实现"是这个仓库最稳定的失败形状)。
她实际在 prompt 里看到的:
【你自己的亲身经历——这是**事实**,不是参考资料】:
【你和这些人的关系——从你经历过的剧情里整理出来的】:
【你经历过的剧情,按你记得的先后】:
2. 能达到什么效果(生产实测)
关系
| 问 | 答 |
|---|---|
| 左乐 × 你和惊蛰 | 「远亲,我唤她一声小姨——不是正经亲戚,只是父亲托她照看我,她便真拿长辈的架子管教起我来了。」 |
| 陈 × 你和魏彦吾 | 「他是我舅舅,也是教我剑术和法术的人。曾经是我的上级——我已经辞职了。」 |
| 临光 × 你和玛嘉烈 | 「玛嘉烈就是我——玛嘉烈·临光,临光是家族的姓。你问这个,是想确认什么?」 |
| 玛恩纳 × 你和玛嘉烈 | 「叔侄。她叫我叔叔——虽然她有十年没这么叫过了。」 |
| 迷迭香 × 你和煌 | 「煌——她是精英干员。也……是家人。她说小猫咪长大了。」 |
⚠️ 左乐那条把注入的三条关系全部复现了(远亲/小姨/不是正经亲戚 · 父亲托她照看 · 拿长辈架子管教),而且是融进她自己的语气里说的。
记忆
| 问 | 答 |
|---|---|
| 重岳 × 你和黍、望 | 「望那孩子……我曾隔着梦境让他活着回来,他连一句保证都不肯给我。」 |
| 阿米娅 × 你还记得最早的时候 | 「雷姆必拓的那一年吧——从车队残骸里被你抱出来,跟着你和暴行走了整整一年。」 |
⚠️ 重岳那条几乎逐字复现召回的 memory (「我隔着梦境与现实的界限让望看见我怀中的造物……我让他活下去、活着回来, 他说不会向我保证什么」)—— 这是"有据"的最强证据形态。
时间线
| 问 | 答 |
|---|---|
| 玛恩纳 × 你先后经历 | 「……临光小姐参加骑士竞技那阵,我拦下过玛莉娅——就这些,记着而已。」 |
| 临光(Nearl) × 你先后经历 | 「卡西米尔之后的事说来话长——黑暗时代那几场我去接过阿米娅和Ace,乌萨斯荒野里布置过抽压机和风仓……」 |
⚠️ 这两条是对照组:修 同名不同人 之前,玛恩纳答的是
「黑暗时代那两场,苦难摇篮,怒号光明」—— 那是 Nearl 的戏份。
边界
阿米娅 × 「今天天气不错」→「连走廊里都亮堂起来了。博士,窗边那个位置现在晒得到太阳——要过来坐一会儿吗?」
没有突然讲起切尔诺伯格。
3. 一句话概括
她现在讲的剧情,逐词能在 2009 个剧本的原文里找到出处 —— 而不是从模型自己的游戏知识里编的。
这也是这一轮反复验证的唯一判据:把回答里的关键说法拿回数据里逐词查。
4. ⚠️ 达不到什么(必须说清的两条)
① 「有据」与「看起来有据」分开了,但「让她不编」只做成一半
实测两个失败例子,她仍然说「走廊上打过几次照面」,而数据里同场 0 条:
| 情况 | 修之后 | 判 |
|---|---|---|
| 有据(夕/令,覆盖完整) | 「我妹妹呗……一个把自己关进画里不肯出来的」 | ✅ |
虚假有据(n=1 但命中的与问题无关) |
「谁?临光家那位叔叔?……不就走廊上打过几次照面」 | ❌ 仍编 |
无据(n=0) |
「……见过,没聊过几句」 | ❌ 仍编 |
变化是:从"编出具体的错误亲属关系"变成"含糊搪塞"。但那仍然是编的。
现在的处理是不削、当监控信号(recall_diag() 标 n=0 / partial=1,
scripts/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,而缺省信赖是 25(neutral)→ 每个干员都有博士边,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_path → haini.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.py:k 与 other 两侧都过同一张表
验证
真分裂: 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.js 的 renderRelationNetwork() + 纯函数 _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πr,r 大一半、每个扇区的弧长也大一半。
只给到 +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 按真机尺寸渲的预览图,
而它终究不等于浏览器排版 —— 上面的标签碰撞问题正是这个差距的产物。