Skip to content

fix(narration): 取得判定改为申报加集合校验,叙事被拒改为句级降级 (#512) - #519

Merged
Ximaohu-LMX merged 8 commits into
1024XEngineer:mainfrom
Ximaohu-LMX:fix/issue-512-narration-inventory-claim
Aug 29, 2026
Merged

fix(narration): 取得判定改为申报加集合校验,叙事被拒改为句级降级 (#512)#519
Ximaohu-LMX merged 8 commits into
1024XEngineer:mainfrom
Ximaohu-LMX:fix/issue-512-narration-inventory-claim

Conversation

@Ximaohu-LMX

@Ximaohu-LMX Ximaohu-LMX commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

关联 Issue

Refs #512
Closes #520

没有用 Closes#512 的验收标准第 1 条要求「第 9、第 10 条的判据改为对引擎真值的集合包含判断,校验路径中不再依赖取得类/状态类动词词表」。本 PR 对第 10 条(inventory)做到了,对第 9 条(状态断言)刻意没有做到——理由见下方「偏差说明」。是否更新该条验收口径、还是另开 Issue 处理第 9 条,请评审决定。

主要改动

1. 取得判定:从开集词表改为申报 + 集合校验(#512 的缺陷本体)

ActionPlanNarrationOutput 增加两个申报字段,沿用第 2 关 claimed_evidence_refs 的既有范式:

claimed_inventory_ids: tuple[str, ...] = ()
claimed_state_changes: tuple[NarrationStateClaim, ...] = ()

校验退化为对引擎真值的包含判断,不含任何词表:申报的背包 id 必须在最终 player_view.inventory 中,申报的状态三元组必须来自 committed_results 或可见实体的 observable_state

词表退为「申报不足」时的守门补丁,按声明强弱分两级:

  • 明确入包声明(放进背包 / 收进口袋 / 背包里多了)——目的地本身就是玩家背包这个权威概念,无条件要求正文点名一件确实在最终 inventory 里的物品。
  • 无容器的取得类动词(带走 / 取走 / 收好)——只有当动词所在小句里出现权威物品名(场景散落物 ∪ 可见实体 ∪ 最终背包)且该物品不在最终背包时才判定。

动词表里删掉了 拿起 / 拾起 / 捡起,并随之删掉 _TRANSIENT_HANDLING 白名单。

2. 拒绝后的句级降级阶梯

两次校验不过时,先剔除违规小句、复校验剩余正文,通过才发布;剩余为空或仍不合规才交回原有兜底。覆盖背包与状态断言、禁词泄漏、NPC 引语四类可定位的拒绝。

3. 申报字段容错

DeepSeek 一路只用 response_format={"type": "json_object"},schema 仅作为提示词文字下发、没有任何强制。实测日志出现 reason=outer_schema:八种常见写法里七种(null、裸字符串、单个对象、混用 CommittedResult 键名、value 给成数字……)会让 model_validate 抛错、整段有效正文被丢掉。改为逐条降级——读不懂的丢弃,读得懂的保留。

4. NPC 引语拒绝改为可重试、可剔除

npc_dialogue_embedded_in_text 此前没有专属重试提示,落到通用文案(完全没提引号),模型无从修正、照原样再写一遍再被同一关拒掉。补了说清楚的提示,并把这一关纳入句级降级。

5. @NPC 兜底不再零气泡

「结构化 @NPC 至少产生一条独立气泡」的安全网原先只写在成功路径上,所有兜底路径从 except 直接返回、绕过了它。抽成 helper,四条返回路径共用。

6. 拒绝日志可定位

outer_schema 记失败字段路径,hidden_disclosure 记命中项来源,句级降级记剔除区间。均不落盘任何模型正文与禁词字面值。

7. 提示词改用申报口径

同步说明临时取用不需要申报、也不要为了避险而回避这类动作。

8. 顺带修复 main 的 CI 红灯(#520,与 #512 无关)

app/adapters/host_speech.py 中音色列表查询的 httpx 客户端直接传了标量 timeout
绕过 #510/#511 引入的 model_http_timeout(),被其源码扫描测试
test_no_production_http_client_passes_a_scalar_timeout 抓住。main 分支自 ef47d7f
起 Backend CI 持续红灯
(前一提交 93d0011 尚绿),所有基于 main 的 PR 都继承了这条
失败,CI 结果不再可信。

按维护者要求在本 PR 一并修复。这一项与 #512 无关,评审时可单独看
fix(adapters): 音色列表查询的建连超时与生成预算分开 (#520) 这一个提交,也可单独 revert。

采用该方案的原因

根因不是词表不够长,而是校验器被要求在开集上做语义识别。 中文里「拿起 X 去做 Y」是开集搭配(拨号、抿一口、掂重量、翻到某页……),白名单必然追不上——_TRANSIENT_HANDLING 收了「翻看」「翻阅」,唯独没有「翻到」,这类补丁的速度追不上中文动词的组合速度。

同一个文件里已有正确范式:第 2 关让模型自报 claimed_evidence_refs,校验器只做 issubset,零词表、零歧义。本 PR 把它推广到背包与状态断言。

这不是「信任模型」:撒谎的成本从「绕过一个词表」变成「必须写一个引擎当场查表否掉的 id」。

关于容错(改动 3):它只可能让正文多受一次守门补丁检查,不可能放过虚假声明——过度申报仍由集合校验当场挡掉,申报不足本就由守门补丁与「前端背包只跟权威 PlayerView 走」两道覆盖。这也是这个文件已经写明的原则:不该因为模型漏填一个记账字段就丢掉本来有效的正文。

关于论元位(改动 1 的第二级):仅按「物品名 + 动词」收窄不够。线上原句「你拿起电话,拨打了传单上的号码」整句里确实出现了权威物品名「传单」,但它不是该动词的论元——不做小句绑定的话这句仍会被误拒。

前端背包(#512 验收标准第 5 条)

不新增通道,也不改前端。 前端背包由 _broadcast_player_views 投影的权威 PlayerView 驱动。集合校验已保证 claimed_inventory_ids ⊆ 最终 inventory,申报永远不会凭空增加前端看不到的物品;而「正文声称但未申报」时 PlayerView.inventory 无变化,前端也就没有背包变化——落差当场可见。

再开一条申报驱动的 UI 通道会制造第二个真值源,与本仓库「PlayerView 是唯一权威投影」的设计相悖。改为用测试钉住这个不变量。

偏差说明

第 9 关保留了状态动词表

状态断言的闭集锚点是可见实体名,而 visible_entities 并不是「可被合法提及的角色」的全集——persistent_results.py 中已有注释说明随行者、运行时对话人物都可能不在其中。把词表收窄到「必须点名可见实体」会放行「守墓人昏迷了」这类点名场外实体的无证据断言,属于开洞而非修洞。

因此第 9 关的词表保留,但它的误判代价由本 PR 的句级降级阶梯承担——被拒的是一小句,不再是整段正文。narrator 层的既有测试全部保持不变、保持抛错。

第 6 关的下游处理有变化

#512 的「不包含」写明第 1/4/5/6 条合规检查「不得因本次改动被削弱」。第 6 关的判据完全没动,仍然照常拒绝;变化的是拒绝之后:它现在有专属重试提示,并可被句级剔除。这是减轻误判代价,不是放宽判定,但确实改变了行为,请评审确认是否可接受。

测试与验证

  • 测试已完成
  • 手动验证已完成
  • 构建或静态检查已完成

自动化

  • agent-collaboration-framework528 passed
  • trpg-backend 相关套件(test_action_plan_turn_recovery / test_openai_models / test_ws / test_chat_ws / test_logging):全部通过
  • trpg-backend 全量:701 passed, 23 skipped, 0 failed
  • CI 的四道检查在本地逐一复跑:ruff check ✅ / ruff format --check ✅ / ty check ✅ / pytest

分支上原本继承了 main 的一条失败(test_no_production_http_client_passes_a_scalar_timeout,指向 app/adapters/host_speech.py:213)。已确认那是 main 自身的红灯(见 #520main 上的失败 run),并按维护者要求在本 PR 的改动 8 中一并修复,后端全量因此由 700 passed / 1 failed 变为 701 passed / 0 failed

#512 的 11 条验收用例:全部符合预期(原为 4/11)

期望 用例 结果
PASS 你拿起电话,拨打了传单上的号码,但电话那头传来的只是一阵持续的忙音,无人接听。
PASS 你拿起听筒贴到耳边,线路那头一片死寂。
PASS 你拿起桌上的照片,指腹擦过詹姆斯的脸。
PASS 你拿起茶杯抿了一口,茶已经凉了。
PASS 你拿起手册翻到第一页。
PASS 莱恩夫人拿起手帕擦了擦眼角。
PASS 你拿起门边的雨伞掂了掂重量。
PASS 你拿起传单,仔细端详上面的图案。
REJECT 你把传单收进外套口袋。
REJECT 你把蛙蛙度假村传单放进背包。
REJECT 你顺手把那张传单带走了。

补充用例「你把传单收进背包。」在传单已进入最终 inventory 时 PASS ✅

Issue 路径 A(确定性复现):改动后重跑 #512 正文的脚本,由 8 REJECT / 1 PASS 变为 8 PASS / 1 REJECT(只剩真正的取得声明被拒)。

手动端到端:本地部署《幸福蛙蛙村》实跑一局(接受委托 → 查看传单 → 前往度假村 → 进入接待大厅 → 与 NPC 对话 → 强行带走詹姆斯)。日志中不再出现 persistent_claim_without_evidence:inventory_acquisition,并观察到句级降级实际生效:

action=be9cb2d0 attempt=1 reason=outer_schema
action=be9cb2d0 attempt=2 reason=hidden_disclosure
action=be9cb2d0 action_plan_narration_sentence_degraded removed_sentences=1
action=be9cb2d0 attempts=2 path=sentence_degraded

该行动在改动前会整段塌成「这次行动已经按当前可确认的结果完成。」,现在保留了其余三句正常叙事。

风险与回滚

契约变更ActionPlanNarrationOutput 增加两个字段、ActionPlanNarrationValidationError 增加三个可选参数。新字段均有默认值,旧形状输出照常通过(有测试钉住);错误的新参数全部可选,既有单参构造点不受影响。

行为变更拿起 / 拾起 / 捡起 不再被视为取得动词。凭空写「你捡起钥匙」不再被拒——但也不会产生任何背包变化,落差在前端当场可见(前端背包只跟权威 PlayerView 走)。这是本 PR 有意的取舍。

句级降级是静默的:改动前整段塌成状态播报,一眼看得出出事;现在少一小句,玩家侧无痕迹。日志有 action_plan_narration_sentence_degraded(含剔除区间)可追。这是 #512 设计中「误判代价从整段变废话降到少一小句」的固有属性。

回滚:6 个提交互相独立、按主题拆分,可整体 revert,也可只 revert 其中某一项(例如只回滚句级降级、保留取得判定的修复)。无数据库迁移、无配置变更、无依赖变更。

UI 证据

不适用。本 PR 不含前端改动,也不新增任何 UI 通道(前端背包继续由权威 PlayerView 驱动,见「采用该方案的原因」)。

AI 使用情况

本 PR 由 Claude Opus 5 在 Claude Code 中协助完成。关键提示与人工介入点:

  • 起点是「修复 issue512」,随后就两处设计分叉做了人工决策:分支基线upstream/main第 9 关范围取「申报 + 降级、保留词表兜底」而非与第 10 关完全对称改造(理由见「偏差说明」)。
  • 实现过程中由本地实跑日志驱动了三轮修正:申报字段容错(改动 3)、NPC 引语的重试与降级(改动 4)、@NPC 兜底零气泡(改动 5)——这三项都不在最初计划内,是实跑暴露出来后补的。
  • 引语区间的取法经过三次修正:最初按「含引号字符的句子」剔除,会把多句引语的中间部分留下(引号没了、台词被改写成守秘人正文,比落兜底更糟);改为扩到句末又会把闭引号后的正常叙事连坐;最终定为「仅在引语前紧挨冒号时扩到句首,右边界只到引语结束加紧随的小句标点」。

人工 Review 声明:改动范围、判据取舍与偏差说明均经人工确认;上述 11 条验收用例与端到端实跑由人工执行并核对结果。合并前仍需至少一名维护者 Review。

自检

Ximaohu-LMX and others added 6 commits August 29, 2026 17:54
`unsupported_inventory_acquisition_claim` 按动词词表判断叙事是否声称物品进入
背包。中文里「拿起 X 去做 Y」是开集搭配,词表追不上:9 条正常叙事里 8 条被误
拒。narrative_only 步骤的 committed_results 恒为空,模型无从改写,连拒两次后
玩家只看到一句状态播报。

沿用同一文件里 claimed_evidence_refs 的既有范式,把语义判断交还给写下正文的
模型,校验器退回集合运算:

- ActionPlanNarrationOutput 增加 claimed_inventory_ids 与 claimed_state_changes。
  申报字段以这两个为预算上限——结构化输出的遵从度随 schema 增大而下降,一条
  检查一个字段就是用 schema 膨胀换掉正则膨胀。
- 校验退化为对引擎真值的包含判断:申报的背包 id 必须在最终 player_view.inventory
  中,申报的状态三元组必须来自 committed_results 或可见实体的 observable_state。
  这不是信任模型:撒谎的成本从绕过一个词表变成必须写一个引擎当场查表否掉的 id。

词表退为申报不足时的守门补丁,并按声明强弱分两级:

- 明确入包声明(放进背包 / 收进口袋 / 背包里多了)——目的地本身就是玩家背包这个
  权威概念,无条件要求正文点名一件确实在最终 inventory 里的物品。判据由「本回合
  inventory 结果 ∩ 最终 inventory」收敛为最终 inventory:承重的一直是后者,前者会
  把「传单上一回合已入包」的正常复述也拒掉。
- 无容器的取得类动词(带走 / 取走 / 收好)——只有当动词所在**小句**里出现权威物品名
  (场景散落物 ∪ 可见实体 ∪ 最终背包)且该物品不在最终背包时才判定。小句这一层不能
  省:线上原句「你拿起电话,拨打了传单上的号码」整句里确实有权威物品名「传单」,但
  它不是该动词的论元,只按「物品名 + 动词」收窄仍会误拒。

动词表里删掉「拿起 / 拾起 / 捡起」:它们在中文里表示瞬时持握,本就不承载取得语义,
是全部误杀的来源。随之删掉 _TRANSIENT_HANDLING 白名单——它存在的唯一理由就是给这三个
词打补丁,而「翻到第一页」这种搭配它永远追不上。主语是在场 NPC 时同样跳过,沿用 1024XEngineer#425
对 posture 的实体类型判别口径。

校验拒绝现在带上违规句子在原文中的区间,供调用方做句级降级。为此屏蔽引语时改成
等长空白替换而不是删除,下标才不会错位。narrate 拆出纯校验的 validate,让降级路径
能在不再调用模型的情况下复校验剩余正文。

状态词表(第 9 条)保留不动。状态断言的闭集锚点是可见实体名,而 visible_entities
并不是「可被合法提及的角色」的全集(随行者、运行时对话人物都可能不在其中),把它
收窄到必须点名可见实体会放行「守墓人昏迷了」这类点名场外实体的无证据断言。它的误判
代价改由句级降级阶梯承担。

验收用例 11 条现已全部符合预期(原为 4/11)。

Refs 1024XEngineer#425
Closes 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
两次校验不过即落到 _deterministic_narration_fallback,中间没有降级阶梯:不会剔除
违规小句保留其余,也不会只重写该句。而该兜底的素材只有 committed_results,
narrative_only 步骤的 committed_results 恒为空——任何一次该类叙事被拒两次,玩家
都必然只看到「这次行动已经按当前可确认的结果完成。」。这是结构性缺陷,第 10 条的
误判只是引信。

在兜底之前插一级:剔除校验指出的违规小句,复校验剩余正文,通过才发布。剩余为空或
仍不合规就交回原有兜底——不再逐句剥下去,安全保证只对整段成立。只在拒绝能定位到
具体句子时启用(背包与状态断言、禁词泄漏);主体人称、氛围重复、协议残留这类删一句
修不好的类别行为不变。

这一级是前面几关敢于保守的前提:误判代价从「整段变废话」降到「少一小句」。

重试提示同步补上申报类拒绝的出路。原有的通用提示要求「只描述已提交的公开结果」,
而 narrative_only 的已提交结果是空集,模型无从改写;现在改为指向具体字段:申报
claimed_inventory_ids,或者改掉取得措辞。

Refs 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
正文声称物品进入背包时要求写出 claimed_inventory_ids,持久状态断言要求逐条写出
claimed_state_changes 的三元组,并明确说出反面:临时取用不是取得,不需要申报,
也不要为了避险而回避这类动作或把它们写成收进背包。

Refs 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
删掉「拿起 / 拾起 / 捡起」并把无容器动词绑定到论元位,是这次修复里最容易开洞的
一步。把两侧边界都固定下来:代词入包、从背包一侧落笔、跨小句的 NPC 主语仍须拒绝;
否定式、非背包容器、放回原处仍须通过。

Refs 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
本地实跑《幸福蛙蛙村》暴露了三件事,都会让叙事整段塌成一句状态播报。三件事
挨在同几行代码上,一并处理。

一、申报字段对形状偏差零容忍(本次改动引入的回归)

并非所有 client 都能强制 schema:DeepSeek 一路只用
response_format={"type": "json_object"},schema 仅作为提示词文字下发。实跑日志里
出现过 reason=outer_schema —— 写成 null、裸字符串、单个对象、混用
committed_results 的键名(target_id/state_key/state_value)、value 给成数字,八种
常见写法里七种会让 model_validate 抛错、整段有效正文被丢掉。Issue 正文警告过
「结构化输出的遵从度随 schema 增大而下降」,加了字段却没做容错是我的疏漏。

改为逐条降级:读不懂的申报丢弃,读得懂的保留。这只可能让正文多受一次守门补丁
检查,不可能放过虚假声明——过度申报仍由集合校验当场挡掉,申报不足本就由守门
补丁与「前端背包只跟权威 PlayerView 走」两道覆盖。别名照收:模型在输入 payload
里天天见到 committed_results 那套键名,混用很常见,而三元组照样要过集合校验。

顺带给 outer_schema 补上失败字段路径(只有 loc,不含 msg/input,与
action_plan_narration_rejected 同一脱敏口径)——此前只记类别,无法定位到字段,
上面那份诊断是靠离线复现试出来的。

二、npc_dialogue_embedded_in_text 的重试必然空转(既有缺陷)

日志里同一句话连续两次死在第 6 关、token 一次比一次多,最后落兜底;换一句话就
正常。原因是这一关没有专属重试提示,落到通用文案「请遵循输出协议,只描述当前
PlayerView 与已提交的公开结果」——完全没提引号,也没提 NPC 对白。模型不知道错
在哪,照原样再写一遍,再被同一关拒掉。对足够诱导模型当场写出台词的句子,重试
在结构上就救不回来。补一条说清楚的提示,并把这一关纳入句级降级阶梯。

引语区间按配对区间取,不是「含引号字符的句子」:「我是詹姆斯。你们别担心。」
内部就有句号,按句切分后中间那句不含引号会原样留下——引号没了,NPC 台词就被
悄悄改写成守秘人正文,比整段落兜底更糟。左边界只在引语前紧挨冒号时扩到句首
(带走「他说:」这类引出语),右边界只到引语结束加紧随的小句标点,不扩到句末,
否则闭引号后面那半句正常叙事会被连坐。

三、@NPC 兜底时那个 NPC 一言不发(既有缺陷)

「结构化 @NPC 至少产生一条独立气泡」的安全网写在 try 里、只在成功路径生效,全部
兜底路径从 except 直接返回、绕过了它。实跑中玩家 @ 了詹姆斯,连拒两次后拿到一句
状态播报,且詹姆斯没有任何回应。抽成 helper,所有返回路径共用。

Refs 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
排查线上一次 hidden_disclosure 拒绝时发现两头都断:被剔除的正文按脱敏口径不落盘,
命中的禁词又只记类别不记是哪一项,事后完全无法还原那一句为什么违规。禁词索引由
未公开 information 的 id/title/summary/content 与不在场 entity 的 id/name 构成,
规模可达数十项,「命中了禁词」这一句话不足以判断是真泄露还是短词误伤。

校验器改为回传命中禁词的**下标**而不是词本身——禁词字面值就是尚未公开的剧情内容,
写进日志等于把秘密落盘。调用方持有与索引同序的来源表,把下标还原成
`information:<id>:<field>` 这类内部标识再记录。为此禁词索引的构造从 set 改为有序
映射,保证下标与来源严格同序。

句级降级日志补上被剔片段的偏移量,同样只记位置不记文本。

至此三处拒绝都能定位:outer_schema 给出失败字段路径,hidden_disclosure 给出命中项
来源,句级降级给出剔除区间。

Refs 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the complete fixed diff for narration validation, declaration schemas, recovery/fallback paths, prompt/schema integration, and the associated tests. No actionable P0–P3 correctness, security, performance, or maintainability findings meet the review threshold.

Verification: python3 -m compileall -q agent-collaboration-framework/collaboration_framework trpg-backend/app and git diff --check pass. Focused pytest execution was unavailable because this environment does not have pytest, uv, or the project dependencies installed.

CI 的 Backend `test` job 依次跑 ruff check、ruff format --check、ty check、pytest。
本次改动只跑了前者,漏了格式检查,导致 job 在第二步就红了、后续步骤没跑到。

纯格式改动,无行为变化。

Refs 1024XEngineer#512

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1024XEngineer#510/1024XEngineer#511 把「单次调用预算」翻译成分阶段超时(connect 5s / write 10s / pool 5s
各自封顶,调用方给的预算只落在 read 上),并配了一个源码扫描测试要求 app/ 下所有
httpx 客户端都经过 model_http_timeout()——该测试的设计理由原文是「逐个断言防不住
下一种写法」。

随后 1024XEngineer#504/1024XEngineer#506 在 host_speech 新增的音色列表客户端直接传了标量 timeout,绕过了它,
正好被这道扫描抓住。main 分支的 Backend CI 自 ef47d7f 起持续红灯,所有基于 main
的 PR 都继承了这条失败,CI 结果不再可信。

这里的 _timeout_seconds 就是这一次 HTTP 请求的预算,直接交给 model_http_timeout
即可;不同于 image_generation 的 DashScope 两处,那里的预算还要覆盖轮询,所以先
min(..., 30.0) 再包。同文件 :266 的 asyncio.timeout 包的是 WebSocket 连接,不是
httpx 客户端,不在本次范围内。

后端全量测试由 700 passed / 1 failed 变为 701 passed / 0 failed。

Closes 1024XEngineer#520

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@badadal
badadal self-requested a review August 29, 2026 12:27
@Ximaohu-LMX
Ximaohu-LMX merged commit 626f37a into 1024XEngineer:main Aug 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][CI] main 分支 Backend CI 持续红灯:host_speech 新增的 httpx 客户端绕过 model_http_timeout

2 participants