Compare commits

..

7 Commits

Author SHA1 Message Date
Jesse_Chen 329f8a2109 fix(rectification): pin the adopt card and drop casual adopt copy
Independent Staging Quality Gate / validate (push) Successful in 11m34s
Independent Staging Quality Gate / publish (push) Successful in 1m56s
The time card stayed under an earlier collect question, and the close said to just use a time for now. Keep the card on the delivery turn and present the analysis as this round's result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 02:03:19 +08:00
Jesse_Chen 7c776bdd3a fix(web): vendor Inter so image builds skip Google Fonts
Independent Staging Quality Gate / validate (push) Successful in 9m0s
Independent Staging Quality Gate / publish (push) Successful in 1m59s
BUG-430 loaded Inter via next/font/google, which failed Gitea publish
run 2408 when the Docker build could not reach fonts.googleapis.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 00:28:26 +08:00
Jesse_Chen 40c623edf6 fix(rectification): bind quality cards to the followup probe
Independent Staging Quality Gate / validate (push) Successful in 23m15s
Independent Staging Quality Gate / publish (push) Failing after 36s
Graduation no longer gets the college-experience question, and identical D24 splits only ask once.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 23:47:51 +08:00
Jesse_Chen 73c2a1a51e docs(report): close BUG-535 after five-theme personal_full ready
Staging at 4ef4c406 produced a standard five-theme document with nine
charts, five chapter bodies, and an empty blocked disclosure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 23:37:24 +08:00
Jesse_Chen 4ef4c4064f fix(report): name final-parse guards and scrub blocked wording
Independent Staging Quality Gate / validate (push) Successful in 12m11s
Independent Staging Quality Gate / publish (push) Successful in 1m57s
Five-theme assemble cleared the chart and actionNotes caps, then failed
three anonymous guards. Log field path plus kind only, and strip
deterministic phrases from sections the producer already marked blocked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 23:09:21 +08:00
Jesse_Chen a68fd01f68 docs(tasks): quality-probe dedupe and not-configured mislabel briefs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-04 14:54:46 +00:00
Jesse_Chen a75929c139 fix(report): keep actionNotes within the document cap at assemble
Independent Staging Quality Gate / validate (push) Successful in 12m42s
Independent Staging Quality Gate / publish (push) Successful in 1m56s
Five-theme personal_full still failed final parse after the charts cap
fix because writer output exceeded actionNotes.max(24). Truncate at
assemble; do not raise the schema cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 22:22:38 +08:00
41 changed files with 1378 additions and 86 deletions
+13 -1
View File
@@ -1,12 +1,24 @@
# 印度占星 Skill 更新日志
## 2026-09-05 — 采用时间卡跟在最新旁白下面,单分钟不再念两遍
生时校正问到可以给出时间时,时间卡跟在最新旁白下面,不再留在更早的采集题底下。旁白会说明按你的经历认真分析过了,下面是这次的结果;单分钟只说一遍钟点。Skill 版本仍是 10.0.14。
## 2026-09-05 — 发布构建不再从网上拉 Inter
西文正文字体改成仓库里的 Inter 文件,镜像构建不再访问 Google Fonts。页面上看起来还是同一套字。Skill 版本未变。
## 2026-09-04 — 入学毕业不再连问两道相同的上大学发挥题
候选区分阶段,同一套发挥选项只问一次。入学和毕业如果切分相同,只问入学那年;毕业不再套「调剂 / 发挥失常」这道题。Skill 版本仍是 10.0.14。
## 2026-09-04 — 采用后核对不再重复已问题,跳过只跳本题
采用代表分钟后,核对题会跳过采集阶段已经问过的前事。核对卡上的「这题跳过」只关掉这一题;没有下一题时收一行「前事核对到这里…」,不再念「选一个先用着」,也不再出现「没有拿到下一个问题」。采用旁白只承诺服务端真会问的核对;采集口语不再加「某年前后,」前缀。Skill 版本仍是 10.0.14。
## 2026-09-04 — 五主题完整报告不再因分盘张数上限装不出来
默认五章(含健康)会带上全部正式分盘。以前文档最多只许 6 张图,五章写完后整份报告仍会被判失败。现在张数上限跟着分盘枚举走。Skill 版本未变。
默认五章(含健康)会带上全部正式分盘。以前文档最多只许 6 张图,五章写完后整份报告仍会被判失败。现在张数上限跟着分盘枚举走;各章行动条超过文档上限时按上限收下;被降级的段落会去掉「一定会」这类说法,不再整份作废。Skill 版本未变。
## 2026-09-04 — 开场打招呼不再被 set-focus 清掉
+91 -10
View File
@@ -6578,15 +6578,15 @@
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 最近更新:2026-09-05
- 影响面:全站 `--font-body`、后台 antd `fontFamily`
- 用户现象:设计稿写了 Inter,实际请求里没有 woff2;非苹果系统落到 Segoe UI / 微软雅黑。
- 触发条件:打开任意带根布局的页面。
- 根因:仓库 0 个字体文件、0 处 `@font-face`、0 处 `next/font`CSS 与 admin token 却把 Inter 写在 StyreneB 之后。
- 修复:根布局用 `next/font/google` 加载 Inter`display: "swap"``--font-inter``--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。
- 修复:根布局用 `next/font/local` 加载仓内 `InterVariable-latin.woff2``display: "swap"``--font-inter`)。早期曾用 `next/font/google`,构建期会访问 Google Fonts,见 BUG-543`--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。
- 验证:`frontend/tests/site-style-isolation-contract.test.ts``next build``/` HTML 含 Inter woff2 preload。
- 防复发:字体栈里出现的西文家族必须有 `next/font` `@font-face`;缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。
- 相关记录:
- 防复发:字体栈里出现的西文家族必须有 `next/font/local`(仓内字体文件)`@font-face`不得用 `next/font/google`缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。
- 相关记录:BUG-543
- 复发自:无
- 修复版本:待发布
@@ -8263,19 +8263,19 @@
## BUG-535 | 五章写完后终稿 final_parse_rejectedaccepted 报告仍无 ready 文档
- 状态:mitigated
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`assembleReportDocumentV2` 之后的 `safeParseServerReportDocument`standard `personal_full` 终装
- 用户现象:报告中心显示生成失败。错误码 `report_schema_invalid`,日志 `innerReason=final_parse_rejected`。五章其实都已写完。
- 触发条件:accepted 生时、默认五主题、staging @ `e27d5dc5`。干净 attempt 1 与此前被租约打断后恢复的 attempt 都复现。
- 根因:`c2f23131``CHART_IDS` 扩到 9 个(加 D6/D8/D30),文档 v2 仍 `charts.max(6)`。分盘提取修好后,五主题装配 9 张图,终稿 parse 拒绝。四主题一直 ≤6 所以从未踩中。
- 修复:v2 `charts.max(CHART_IDS.length)`JSON Schema / Python 合同同步;装配允许集改为 `CHART_IDS``final_parse_rejected` 日志增加 `parsePaths`仅 path + code
- 验证:合同测试 9 图过 parse;五主题分章夹具 READY四主题不回归;超限仍 `too_big`。staging 真实 standard personal_full 待任务 3
- 防复发:上限必须绑定 `CHART_IDS.length`,不得再写裸 6/9。扩枚举必须同时改 Zod / JSON Schema enum / Python `CHART_IDS`。终稿失败必须带 parse path。
- 根因:`c2f23131``CHART_IDS` 扩到 9 个(加 D6/D8/D30),文档 v2 仍 `charts.max(6)`。分盘提取修好后,五主题装配 9 张图,终稿 parse 拒绝。四主题一直 ≤6 所以从未踩中。随后真实五章又踩中 `actionNotes.max(24)`,再踩中匿名 contract guard。
- 修复:v2 `charts.max(CHART_IDS.length)`JSON Schema / Python 合同同步;装配允许集改为 `CHART_IDS`装配把 `actionNotes` 截到合同上限 24,不放宽 schema。`final_parse_rejected` 日志 `parsePaths` 仅 path + code;guard 失败改为字段路径 + 种类。blocked 段去掉确定性用语
- 验证:合同测试 9 图过 parse;五主题分章夹具 READY每章 6 条行动仍 READY 且 notes=24。staging request `31025c49``actionNotes/too_big``a75929c1` request `f10daf6d`:三条 `(guard)/guard``4ef4c406` request `0679321b`**ready**v2 文档 9 张图、五章有正文、actionNotes=22、blocked 披露为空,阅读页可打开
- 防复发:上限必须绑定 `CHART_IDS.length`,不得再写裸 6/9。扩枚举必须同时改 Zod / JSON Schema enum / Python `CHART_IDS`装配侧截断须跟合同上限同一常量。终稿失败必须带可区分的 parse path,不得只写 `(guard)`
- 相关记录:BUG-526、BUG-534;引入半拉子改动的提交 `c2f23131`
- 复发自:无
- 修复版本:`61194690`
- 修复版本:`4ef4c406`
## BUG-536 | 采用后第一道核对题重复已问过的采集题
@@ -8340,3 +8340,84 @@
- 相关记录:BUG-414、BUG-418
- 复发自:无
- 修复版本:待发布
## BUG-540 | 候选区分阶段同一道「上大学」发挥题问两次,第二次绑错证据
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`choice-card.ts::pickProbe``ChoiceCardFollowup.probe_id`
- 用户现象:候选区分阶段连续两张选择卡题干、选项完全相同(「某年某月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清」)。用户两次都作答,第二次答完范围继续收窄。
- 触发条件:学业域已核实入学与毕业两条事件;引擎对两条各发一条 `known_event_quality`;第二张卡的 followup 指向毕业探针。
- 根因:`pickProbe``choice_kind === "event_quality"` 时取池里第一条质量探针就返回,`semantic_key` 匹配写在后面,永远轮不到。`periodFor``eventQuestionPrompt` 都从这条错探针取日期和题干。用户第二次是对着入学题给毕业探针打分。
- 修复:匹配顺序改为 `probe_id``semantic_key` → 无键时才按种类兜底。有键但找不到对应探针时返回 `null`,不出卡,不再退回同种类第一条。`question_id` 在有键时带上该键,避免两张质量卡共用一个 id。
- 验证:同域两条质量探针、followup 指向第二条时,`buildChoiceFrame``question_id`、日期标签、`user_meaning` 全部来自第二条;指向第一条不受影响。有键找不到探针时 `buildChoiceFrame``null``rectification-choice-card` 既有 varga_style / existence 断言仍过。
- 防复发:不得先按 `choice_kind` 取第一条质量探针再匹配 `semantic_key`。有 `probe_id` / `semantic_key` 时不得退回同种类第一条。
- 相关记录:BUG-390、BUG-541
- 复发自:无
- 修复版本:待发布
## BUG-541 | 毕业等学业 kind 套高考发挥题,同切分质量探针占满名额
- 状态:resolved
- 首次发现:2026-09-04
- 最近更新:2026-09-04
- 影响面:`event_probes.py::_quality_distinguish_probes`
- 用户现象:毕业事件也被写成「上大学 / 调剂 / 发挥失常」。即便卡面绑对了,第二道仍不带来新切分信息。
- 触发条件:学业域同时有入学与毕业;两条 `known_event_quality` 的 yes/no 分钟集合相同(同一 D24 星座切分),只有 `semantic_key` / `target_evidence_id` / `candidate_split_hash` 不同。
- 根因:(1) `_quality_user_meaning``QUALITY_DISTINGUISH_OPTIONS["education"]` 只看 `domain == "education"`,不看 `event_kind`。采集线会产生 `education_completion`,模板仍是 BUG-390 的高考发挥题。(2) `candidate_split_hash` 掺了年份,同分组拦不住;`MAX_QUALITY_DISTINGUISH_PROBES = 2` 被两条信息相同的探针占满。
- 修复:质量探针只对 `education_start` / `education_change` / `education_interruption` 发出;`education_completion` 与其它 kind、以及非学业域不发。同域同 yes/no 集合只保留信息增益最高、并列取时间最早的一条;被去掉的不计入名额。不改四选项文案,不改 `candidate_split_hash`,不新造毕业体验模板。
- 验证:入学 + 毕业 → 一条质量探针且 `target_evidence_id` 指向入学。仅毕业 → 零条质量探针;训练门仍开时仍有 dasha 存在性探针。两条可发事件同分组 → 一条;`_select_quality_distinguish_rows` 不同分组 → 两条,第三条不超过上限 2。`test_probe_question_contract` 四选项合同仍过。
- 防复发:学业质量探针必须看 `event_kind`(或 `kind`),不得对 `education_completion` 发。同域同 yes/no 集合不得发第二条。不得改 `QUALITY_DISTINGUISH_OPTIONS` 文案或 `candidate_split_hash` 算法来「修」去重。
- 相关记录:BUG-390、BUG-540
- 复发自:BUG-390(质量探针只对学业发出,但学业内 kind 未收紧)
- 修复版本:待发布
## BUG-543 | staging 镜像构建因 Google Fonts 拉不到 Inter 失败
- 状态:resolved
- 首次发现:2026-09-05
- 最近更新:2026-09-05
- 影响面:Gitea `backend-quality-gate.yml` `publish``deploy/railway-web.Dockerfile``RUN npm run build``frontend/src/app/layout.tsx`
- 用户现象:无终端用户可见现象。门禁 validate 通过后 publish 失败,staging 不发布新镜像。
- 触发条件:推送触及门禁路径后,publish 在 Docker 内执行 `npm run build`。构建环境访问不到 `fonts.googleapis.com`
- 根因:BUG-430 用 `next/font/google` 在构建期下载 Inter。validate 跑在 hostexecutor,能出网;publish 的镜像构建不能。Gitea run 2408SHA `40c623ed`)报 `Failed to fetch Inter from Google Fonts``railway-web.Dockerfile:26` 退出 1。
- 修复:把 latin 可变 InterOFL)放进 `frontend/src/app/fonts/InterVariable-latin.woff2`,根布局改 `next/font/local`。不改 workflow、不设构建期代理。字体栈与 `--font-inter` 不变。
- 验证:`site-style-isolation-contract` 锁定 `next/font/local`、仓内 woff2 魔数、以及 `frontend/src` 不再出现 `next/font/google` / `fonts.googleapis.com`。本机 `npx tsx --test tests/site-style-isolation-contract.test.ts``tsc --noEmit`
- 防复发:不得把西文正文字体改回 `next/font/google`。新字体必须是仓内文件 + `next/font/local``@font-face`
- 相关记录:BUG-430
- 复发自:BUG-430(加载方式在无 Google Fonts 网络的镜像构建里不成立)
- 修复版本:待发布
## BUG-544 | 采用卡钉在历史采集题下,最新旁白却说从下面选
- 状态:resolved
- 首次发现:2026-09-05
- 最近更新:2026-09-05
- 影响面:`attachOfferResultToTurns``applyLiveCandidateOffer``rectification-agentic-chat``candidateOffer` 锚点
- 用户现象:最新助手说「可以从下面选一个先用着」,下面没有时间卡。往上翻,卡钉在更早的「工作上呢,还记得哪年入职…」采集题下面。
- 触发条件:公开 `can_adopt` 曾为真时,当时最新助手消息还是未答完的采集题;之后继续问了家人/核对,再出采用旁白。
- 根因:三层叠加,复发 BUG-497/499。(1) 客户端 `useEffect` 只要某条消息已有同一 `resultId` 就不再改锚点。(2) `messages.find` 取第一条 `candidateOffer`,旧采集题永远赢过后面的采用旁白。(3) GET 把 `offer_result_id` 写在最新回合上时,`mergeTurnQuestions` 在旧消息 `offer_result_id` 为空时仍保留客户端脏锚点。
- 修复:未采用时卡只挂在最新已落地、且没有未答采集/区分题的助手消息上;未答采集/选择题在场则不出卡。已采用后仍不把卡挪到核对题上。GET 水合时空的 `offer_result_id` 清掉该回合的客户端脏锚点。
- 验证:`rectification-candidate-offer-anchor.test.ts`:采集题 + 后续旁白 → 卡在旁白;只有未答采集 → 无卡;已采用后卡留在原消息。`agent-voice-copy-contract``rectification-agentic-entry``rectification-adopt-flow-20260902` 既有合同仍过。
- 防复发:未采用不得把采用卡锚在未答 `collect_spoken` / `choice` 上。不得用 `messages.find` 取第一条 offer。GET 空 `offer_result_id` 不得保留客户端脏锚点。已采用后仍不得把卡锚到最新核对题。
- 相关记录:BUG-312、BUG-497、BUG-499、BUG-545
- 复发自:BUG-497、BUG-499
- 修复版本:待发布
## BUG-545 | 单分钟采用旁白把同一时刻念两遍,并说「分不开当前候选」
- 状态:resolved
- 首次发现:2026-09-05
- 最近更新:2026-09-05
- 影响面:`nonConvergingRangeNarration``templateStopExplain``RECTIFICATION_USER_COPY.adoptCue`
- 用户现象:交付句为「剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。这只是代表性候选,不是已确认的唯一出生分钟。可以从下面选一个先用着。」听起来像内部状态,不像对人说话。
- 触发条件:可信区间收成同一分钟,且 `stop_facts` 没有可并列的第二分钟。
- 根因:模板把「范围」和「代表分钟」无条件拼在一起;`formatClockRange``05:1505:15` 收成 `05:15` 后仍套「范围是 X,代表分钟 X」。停问原因用「当前候选」这种内部词。`adoptCue` 说「选一个」,卡片却不在这句下面(见 BUG-544)。
- 修复:区间收成单分钟时改口「眼下更站得住的是 HH:MM」,不再重复。停问改成「再问下去也分不开 A 和 B」或「再问下去也分不出更准的时间了」。`adoptCue` 改为「我按你说的经历认真分析过了,下面是这次的结果。」不得说「先用着」。边界句「不是已确认的唯一出生分钟」保留。
- 验证:`agent-voice-copy-contract` 锁单分钟句式与 `adoptCue``rectification-answer-choice` 最后一题采用旁白匹配新句式或跨分钟「代表分钟」。机器词表加入旧「当前候选 / 可以从下面选一个先用着 / 下面的时间可以先用着」。
- 防复发:单分钟区间不得把范围和代表分钟念两遍。用户可见文案不得出现「当前候选」。交付收尾不得说「先用着」。交付/采用轮仍须出现代表性分钟边界语义。
- 相关记录:BUG-544、VOICE.md
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,40 @@
# PROGRESS · 采用卡锚点与交付旁白(2026-09-05)
工作树:`.worktrees/rectification-quality-probe-dedupe-20260904`
分支:`codex/rectification-quality-probe-dedupe-20260904`
基线:`origin/staging` `7c776bdd`Inter 本地字体)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 采用卡跟最新旁白,采集题下不出卡 | 完成 | BUG-544 |
| 单分钟交付旁白去机器腔 | 完成 | BUG-545 |
## 编号
开工时最大号 BUG-543。本单 **BUG-544、BUG-545**。未占用预留的 BUG-542。
## 实现要点
- `attachOfferResultToTurns` / `applyLiveCandidateOffer`:未采用时跳过未答 `collect_spoken` / `choice`;卡挂在最新已落地助手消息。已采用后不挪到核对题。
- GET 水合:该回合 `offer_result_id` 为空则清掉客户端脏锚点。
- 单分钟区间:`眼下更站得住的是 HH:MM`。停问:`再问下去也分不开 A 和 B` / `再问下去也分不出更准的时间了``adoptCue``我按你说的经历认真分析过了,下面是这次的结果。`
## 断言变更
| 项 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `adoptCue` | 可以从下面选一个先用着。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 付费校正不是随便先用着;仍不说成已确认 |
| `rectification-answer-choice` 最后一题旁白 | `/代表分钟/` | `/眼下更站得住的是\|代表分钟/` | 单分钟区间不再念「代表分钟 05:00」 |
| `site`/`agentic-entry` 卡锚点 | `messages.find` 第一条 offer | `reverse().find` + `applyLiveCandidateOffer` | 旧采集题抢锚点 |
## 测试
| 命令 | 结果 |
| --- | --- |
| `npx tsx --test tests/rectification-candidate-offer-anchor.test.ts tests/agent-voice-copy-contract.test.ts` | 15/15 |
| 上两项 + adopt-flow / adopt-narration / answer-choice / collect-stall / provisional-adopt / range-offer / agentic-entry / activity-receipt | 160/160 |
| `tsc --noEmit` | 0 错 |
| 改动文件 eslint `--quiet` | 0 error |
| `page.tsx` | 未改 |
未做浏览器真人走查。清单:`docs/testing/rectification-adopt-card-anchor-20260905.md`
@@ -0,0 +1,54 @@
# PROGRESS · 学业质量探针去重(2026-09-04
工作树:`.worktrees/rectification-quality-probe-dedupe-20260904`
分支:`codex/rectification-quality-probe-dedupe-20260904`
基线:任务书写 `origin/staging` `a75929c1`;开工时 `origin/staging` 已是 `a68fd01f`(含本任务书)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`2041 行)。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 `pickProbe` 按键选探针 | 完成 | BUG-540 |
| 5.2 质量探针按事件 kind 发 | 完成 | BUG-541 |
| 5.3 同域同分组去重 | 完成 | BUG-541 |
| 5.4 记录 | 完成 | — |
## 编号
开工时 `docs/BUG_HISTORY.md` 最大号 BUG-539。本单 **BUG-540、BUG-541**。未占用并行任务书预留的 BUG-542。
## 实现要点
- `pickProbe``probe_id``semantic_key` → 无键才按 `choice_kind` 兜底。有键找不到 → `null``buildChoiceFrame` 不出卡。`ChoiceCardFollowup``probe_id``makeFollowup` 已有字段,透传即可。
- `question_id` 在 followup 带 `semantic_key` / `probe_id` 时拼进该键,避免两张质量卡共用 `d5_education:education_style:score`
- `_quality_distinguish_probes` 只对 `event_kind ∈ {education_start, education_change, education_interruption}` 的学业事件发;`education_completion` 与其它 kind、非学业域不发。字段读 `event_kind``kind`
- 同域同 yes/no 分钟集合只留信息增益最高、并列取时间最早的一条;被去掉的不计入 `MAX_QUALITY_DISTINGUISH_PROBES``candidate_split_hash` 与四选项文案未改。
## 偏离
- 任务书验收「不同分组 → 两条」在 `_quality_distinguish_probes` 里同域事件共用同一套 D24 yes/no,单次调用不会出现不同分组。不同分组与上限 2 的断言写在 `_select_quality_distinguish_rows`5.3 实际去重函数)。
- 「仅毕业仍参与普通时间探针」:3 条事件会被 holdout 抽走 1 条,训练门关、整表空。测试补了第四条家人事件让训练门仍开,断言仍有 dasha 存在性探针、零条质量探针。
- 有键找不到探针 → 不出卡:新测试覆盖;既有 `rectification-choice-card` 夹具没有出现过这种情况。
- 非学业域的区分质量探针一并停发(与 BUG-390「质量探针只对学业发出」对齐)。既有「事业不发质量卡」测试仍过。
## 测试
| 命令 | 结果 |
| --- | --- |
| `.venv/bin/python -m pytest tests/test_rectification_event_probes.py tests/test_probe_question_contract.py -q` | 40 passed |
| `npx tsx --test tests/rectification-choice-card.test.ts tests/rectification-probe-question-contract.test.ts` | 47/47 |
| 上两项 + `rectification-eight-method` + `rectification-answer-choice` | 140/140 |
| `tsc --noEmit` | 0 错 |
| `npx eslint . --quiet` | 0 error |
| `page.tsx` | 2041 行,未改 |
| `run_quality_gate.py --profile quick``npm test` | 2682 tests / 2673 pass / **9 fail**,全部是 Docker 迁移 `database migration failed``database-foundation` / `database-local-business` / redeem / identity / secret-refs / v9×3 / Skill migration)。与本单无关,属既有环境缺口。 |
未跑全量 `npm test`(时间);未做浏览器真人走查(无登录态)。清单:`docs/testing/rectification-quality-probe-dedupe-20260904.md`
## 门禁修复(BUG-543
Gitea run 2408`validate` 通过,`publish``railway-web.Dockerfile` `RUN npm run build` 失败。报错 `Failed to fetch Inter from Google Fonts`。根因是 BUG-430 的 `next/font/google` 在镜像构建里访问不到 Google。未改 workflow。
| 项 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `site-style-isolation-contract` Inter 加载断言 | `import { Inter } from "next/font/google"` | `import localFont from "next/font/local"` + 仓内 woff2 魔数 + `frontend/src` 禁止 `from "next/font/google"` / `fonts.googleapis.com` | 构建期不得出网拉字体 |
本机补充:`npx tsx --test tests/site-style-isolation-contract.test.ts` **4/4**`tsc --noEmit` 0 错;改动文件 eslint 0 error。本机 `npm run build` 被 worktree 里 `frontend/node_modules` 指向仓外的 symlink 挡住(Turbopack `points out of the filesystem root`),属既有环境缺口;CI publish 镜像内是 `npm ci`,无此 symlink。编号:BUG-543(未占用预留的 BUG-542)。未改 `.gitea/workflows/**`
@@ -10,13 +10,15 @@
| --- | --- | --- |
| 1 上限绑定 + 消费点核对 + 回归 | 完成 | BUG-535 |
| 2 终稿 parse path 日志 | 完成 | BUG-535 |
| 3 部署后真实 personal_full | 待 health SHA 对齐后做 | BUG-535 |
| 3 部署后真实 personal_full | 完成:`4ef4c406` request `0679321b` ready,九图五章,blocked 空 | BUG-535 |
## 实现要点
- v2 文档 `charts.max(CHART_IDS.length)`,单一真源,不写裸数字 9。
- 装配侧 `assembleReportDocumentV2` 的允许分盘集合改为 `new Set(CHART_IDS)``DOCUMENT_VARGA_CHART_IDS``CHART_IDS` 去掉 `D1` 派生。
- 终稿 `safeParseServerReportDocument` 失败时,`generation_failed` 增加 `parsePaths: [{path, code}]`。只记路径与 zod/guard 代号,不记 `message`、不记正文或 hash 值
- 装配把 `actionNotes` 截到 `REPORT_DOCUMENT_V2_ACTION_NOTES_MAX`24),**不放宽** schema。staging 第一次五章写完后又 `final_parse_rejected``parsePaths` 只有 `actionNotes`/`too_big`
- 终稿 `safeParseServerReportDocument` 失败时,`generation_failed` 增加 `parsePaths: [{path, code}]`。只记路径与 zod/guard 代号,不记 `message`、不记正文或 hash 值。合同 guard 不再一律写成 `(guard)/guard`,而是字段路径 + 种类。
- 装配后的 `applyReportGuard` 把章节标成 blocked 时,会去掉「一定会」这类确定性用语,并让 `currentPhase` 跟 timing 章一致。**不放宽** parse 侧字段校验。
- JSON Schema `maxItems` / chart `enum`、Python `CHART_IDS``len(CHART_IDS)` 与 TS 对齐(合同三份真源)。冻结 skill 包 `6.9.14` / `6.9.15` 未动。
## 消费点核对(红线 3
@@ -42,7 +44,9 @@
- 超过 `CHART_IDS.length``too_big`
- 四主题既有用例不回归
- 五主题(含 health)分章管线 READY,文档 charts 正好 9 张、blocked 为空
- 五主题每章 6 条行动仍 READY,`actionNotes.length === 24`
- `final_parse_rejected` 日志含 `thematicNarrative.0.title` / `too_big`,不含超长标题正文
- guard 失败日志含字段路径与种类(如 `requestedThemes`/`theme_coverage``actionNotes[0].note`/`forbidden_medical_diagnosis`),不含违规正文
- Python `test_v2_accepts_full_chart_ids_set`
## 门禁输出
@@ -51,9 +55,22 @@
- `npm run lint`0 error / 74 warning(既有,未动)
- 定向:`personal-report-contract` + `personal-report-generation-v2` + `personal-report-api`**110 pass / 0 fail**(含新增 9 图 parse、五主题 READY、超限 too_big、parsePaths 日志)
- `.venv/bin/python -m pytest tests/test_personal_report_contract.py`**40 pass**(含 `test_v2_accepts_full_chart_ids_set`
- 定向补跑 contract + generation + generation-v2**96 pass**(含 guard 路径代号、blocked 段去掉「一定会」)
- 全量 `npm test`Docker 库测争用超时(`database-*` 与部分会起 Postgres 的套件),与既有环境缺口同类;与本单无关
- `next build`:本单未改 UI/路由,未跑;任务 3 部署后用 health SHA 验收
- `next build`:本单未改 UI/路由,未跑
### 任务 3 第一次(health `d4627a50`,含 `61194690`
request `31025c49`5× `consultation_workflow` 200;五章 ready;摘要 `stop`。终稿仍 `final_parse_rejected`。日志 `parsePaths: [{path:"actionNotes", code:"too_big"}]`——charts 上限已不是这条失败原因。未放宽 `actionNotes.max(24)`,装配截断到该上限后再验。
### 任务 3 第二次(health `a75929c1`
request `f10daf6d`:五章 ready。终稿 `final_parse_rejected``parsePaths` 三条都是 `(guard)/guard`。charts 与 actionNotes 已过。日限默认 5 当天用满,staging 进程临时读 8(下次正式部署前 env 文件置回 8,验收后再恢复 5)。未放宽 schema。
### 任务 3 第三次(health `4ef4c406`
request `0679321b`**ready**。文档 v2charts=9D1/D2/D6/D8/D9/D10/D11/D24/D30),五章有正文(career 1459 / health_pressure 1224 / marriage 1016 / timing 744 / wealth 1136 字),actionNotes=22(上限 24),blocked 披露=0。阅读页可打开。Writer `finishReason=stop`;章 inputTokens 5866 / 6402(随后 repair 6435/ 5806 / 6036 / 5921,摘要 1542。墙钟约 13s / 9s+21s / 10s / 8s / 12s / 3s。成本 5.22× 不在本单。staging 日限 env 文件已恢复 5。
## 让步
无。JSON Schema 的 `maxItems` 只能写数字,用合同测试把它锁到 `CHART_IDS.length`
无。JSON Schema 的 charts `maxItems` 只能写数字,用合同测试把它锁到 `CHART_IDS.length``actionNotes` 合同上限未放宽。
+4 -1
View File
@@ -50,6 +50,9 @@
| `TASK-rectification-collect-direction-20260904.md` | `PROGRESS-rectification-collect-direction-20260904.md` | 可评分事件 2 条时盘外核对抢跑到刚拒答的家人领域,Agent 只能改写成不指向任何领域的泛问;缺第三件带年份的事却先问职业 | 已验收:5.1/5.3/5.4 通过;5.2 顺序错位(任务书责任)见修复单 | `codex/rectification-collect-direction-20260904`BUG-527530 |
| `TASK-rectification-collect-direction-fix-20260904.md` | `PROGRESS-rectification-collect-direction-fix-20260904.md` | dated 补采集分支插在方法轮转之前,新案例第二问被家人抢占感情/事业;`7ee7f825` CSS 与合同测试脱节挡住门禁 | 已验收通过(0be51e65),待 staging 部署核对 | `codex/rectification-collect-direction-fix-20260904`BUG-531532 |
| `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`BUG-536539 |
| `TASK-rectification-quality-probe-dedupe-20260904.md` | `PROGRESS-rectification-quality-probe-dedupe-20260904.md` | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`BUG-540541 |
| — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`BUG-544545 |
| `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`BUG-542 起) |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505509 |
### 聊天主链路与首页
@@ -77,7 +80,7 @@
| `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265``5c0bec0c``fbd6e480``cf6405ed` |
| `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`BUG-524 |
| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `e27d5dc5`BUG-526/534;终稿见 BUG-535 |
| `TASK-report-chart-cap-20260904.md`(仓库根) | `PROGRESS-report-chart-cap-20260904.md` | 文档 charts 上限绑定 CHART_IDS | 执行中 | `61194690`BUG-535 |
| `TASK-report-chart-cap-20260904.md`(仓库根) | `PROGRESS-report-chart-cap-20260904.md` | 文档 charts 上限绑定 CHART_IDS | 待验收 | `4ef4c406`BUG-535 |
### 前端基础与工程
@@ -0,0 +1,72 @@
# TASK · 部署/数据库瞬断被报成「服务尚未配置」(2026-09-04)
- 基线:`origin/staging` @ `a75929c1`
- 分支:`codex/api-not-configured-mislabel-20260904`
- 执行方:coding agent;验收:Claude
- BUG 编号起点:**BUG-542**(若 `TASK-rectification-quality-probe-dedupe-20260904.md` 尚未开工则从 BUG-540 起;开工时 `grep -o "BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | sort -u | tail -1` 复核)
## 1. 事故实证
2026-09-04 约 22:00UTC+8)用户在 staging 校正面点选答案,`POST /api/rectification/agent``GET /api/rectification/cases/<id>?sessionId=…` 均返回 `503 {"error":"服务尚未配置"}`。同一时刻 `GET /api/health`
| 字段 | 当时 | 数分钟后 |
| --- | --- | --- |
| `deployment.gitCommit` | `a75929c1`(刚落地) | `a75929c1` |
| `status` | `blocked` | `ok` |
| `database.latestMigration` | `null` | `20260904010000_read_report_candidate_range.sql` |
| `database.requiredMigrationsPresent` | `false` | `true` |
| `checks.rectificationMigrations` | `degraded` / `"Error"` | `ok` |
| `checks.modelCatalog` | `blocked` / `database_model_catalog_unavailable` | `ok` |
即:数据库在部署切换窗口内短暂不可用,随后自愈;环境变量从未缺失。
## 2. 根因
16 处路由用同一段兜底(示例 `frontend/src/app/api/rectification/cases/[caseId]/route.ts` L3845、`frontend/src/app/api/rectification/agent/route.ts` L143153):
```
try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); }
catch { return 503 {"error":"服务尚未配置"} }
```
`createServerSupabaseClient()` 在自托管分支里会先读身份会话(`readIdentitySession` → 数据库查询),任何数据库连接错误都从这里抛出,被 `catch` 一律翻译成"尚未配置"。文案本来只对应 `SupabaseConfigurationError``frontend/src/lib/supabase/config.ts``code = "DATABASE_NOT_CONFIGURED"`,缺环境变量时抛出)。
全部 16 处:`api/rectification/{agent, cases/open, cases/entry-summary, cases/[caseId], cases/[caseId]/{close, adopt-skill, upgrade-skill, candidates/accept, turns/[turnId]/regenerate}}``api/consult/{route, status, cancel}``api/models``api/birth-time-journey``api/birth-time-guide``lib/onboarding-post.ts`
## 3. 决策记录
1. 只有 `isSupabaseConfigurationError(error)` 为真时才允许返回"服务尚未配置";其它异常一律 `503 {"error":"服务暂时不可用,请稍后重试","code":"service_unavailable"}`,并 `console.error` 带路由名与 `error.name`(不打印堆栈里的连接串)。
2. 不新增重试、不改部署顺序、不动 health 判定;部署窗口内的短暂 503 是可接受的,这里只修**说法**,让用户与验收方不再误以为环境变量丢了。
3. 16 处收敛为一个共享 helper(放 `frontend/src/lib/api/` 或既有的 route 工具目录,位置由执行方按现有惯例选),不得复制 16 份新文案。
## 4. 硬红线
- 不得改 `createServerSupabaseClient` / `createAdminSupabaseClient` 的抛错语义与调用顺序。
- 未登录仍是 `401`,与本任务无关的路径不得顺带改动。
- 不得在错误响应里回显任何环境变量名之外的信息;配置错误响应保留现有 `"服务尚未配置"` 文案(既有测试引用)。
## 5. 任务分解
### 5.1 共享 helper + 16 处替换
- 验收:`grep -rn "服务尚未配置" frontend/src` 只命中 helper 一处;`tsc --noEmit``npm run lint` 0 error。
### 5.2 回归测试
- 新增 `frontend/tests/api-service-unavailable-20260904.test.ts`:模拟 `createServerSupabaseClient` 抛 (a) `SupabaseConfigurationError` → 503 `服务尚未配置`(b) 普通 `Error("connection refused")` → 503 `code=service_unavailable`,文案不含"尚未配置"。至少覆盖 `rectification/agent``cases/[caseId]` 两条路由(Node 22 module mock 契约见 `docs/BUG_HISTORY.md``test(reports): document Node 22 mock contract` 相关条目)。
- 既有引用"服务尚未配置"的测试若断言的是非配置类异常,改断言并写"原值 / 新值 / 原因"。
### 5.3 记录
- `docs/BUG_HISTORY.md` 新增一条(现象、触发条件=部署切换窗口、根因=兜底翻译、修复、验证);`CHANGELOG.md` 一条;`docs/tasks/PROGRESS-api-not-configured-mislabel-20260904.md`
## 6. 让步顺序
5.1 > 5.2 > 5.3。若某条路由的测试 harness 不支持 mock,只覆盖两条主路由并把其余写进进度记录。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/api-not-configured-mislabel-20260904 .worktrees/api-not-configured-mislabel-20260904 origin/staging
cd .worktrees/api-not-configured-mislabel-20260904
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
grep -rn "服务尚未配置" frontend/src frontend/tests | wc -l
```
@@ -0,0 +1,81 @@
# TASK · 校正候选区分阶段同一道"上大学"题问两次(2026-09-04
- 基线:`origin/staging` @ `a75929c1`(含 `d4627a50` 采用后核对线修复;本任务书不依赖其验收结论)
- 分支:`codex/rectification-quality-probe-dedupe-20260904`
- 执行方:coding agent;验收:Claude
- 涉及文件:`scripts/rectification/event_probes.py``frontend/src/lib/rectification-agentic/v9/choice-card.ts`;与 `TASK-rectification-post-adopt-verify-20260904.md`(已实现 `d4627a50`)无文件冲突,可并行。
- BUG 编号起点:**BUG-540**(开工时用 `grep -o "BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | sort -u | tail -1` 复核)
## 1. 事故实证(staging2026-09-04,候选区分阶段)
用户在同一案例里连续两次看到同一张选择卡:"**YYYY 年 M 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清**",四个选项完全相同。用户两次都作答,第二次答完范围继续收窄。案例 dump 中可对上的事实(不写具体值):
| # | 事实 | 位置 |
| --- | --- | --- |
| A | 该案例学业域有两条已核实事件:**入学**(`education_start`)与**毕业**`education_completion`),相隔四年 | 案例 `evidence[]` |
| B | 引擎对这两条事件各发了一条 `known_event_quality` 探针;两条的 `expected_outcomes` 的 yes/no 候选分钟集合**完全相同**(同一 D24 星座切分),只有 `semantic_key``education.<入学年>.known_event_quality` / `education.<毕业年>.known_event_quality`)、`target_evidence_id``candidate_split_hash` 不同 | `scripts/rectification/event_probes.py::_quality_distinguish_probes`L901997)、`candidate_contrast.py::candidate_split_hash`hash 把 `year/month` 掺进去,所以同分组不同年 ≠ 同一 hash) |
| C | 第二张卡的 `question_id` / `probe_id` / 评分都指向**毕业**探针,但卡面题干与日期标签是**入学**探针的 | `choice-card.ts::pickProbe`L233251):`followup.choice_kind === "event_quality"` 分支取 `pool` 里**第一条**质量探针就返回,`semantic_key` 匹配写在它后面,永远轮不到;`periodFor`L254)与 `eventQuestionPrompt`L280)都从 `pickProbe` 拿探针,所以日期与题干一起错 |
| D | 即便 C 修好,毕业事件的题干仍会是"YYYY 年 6 月那次**上大学**……"、选项仍是"发挥明显失常 / 只是将就调剂 / 基本如愿录取 / 当时说不清楚" | `event_probes.py::_quality_user_meaning`L887)只按 `domain == "education"` 套"上大学"模板;`QUALITY_DISTINGUISH_OPTIONS["education"]`L92)同样不看事件 `kind` |
## 2. 根因
1. **前端选探针的优先级反了**`pickProbe` 先按"种类"再按"键"。同域只有一条质量探针时看不出来;一旦引擎给了两条,第二次 followup 的卡面永远渲染第一条。用户第二次实际是在**对着入学题、给毕业探针打分**——答案被记到了错的证据上。
2. **引擎对学业域所有事件一律发"高考发挥"题**:质量探针的题干与选项是 BUG-390 时按"高考/录取"设计的,那时只有 `education_start` 一种学业事件会到这里;现在采集线会产生 `education_completion`(毕业)等其它 kind,模板没有跟着分。毕业没有"调剂/发挥失常"这种体验,这道题对毕业事件**没有可回答的语义**。
3. **同分组质量探针没有去重**:两条探针的 yes/no 分钟集合一模一样,问第二道不带来任何新信息(信息增益相同、支持/冲突集合相同),却占了 `MAX_QUALITY_DISTINGUISH_PROBES = 2` 的全部名额和用户一轮回答。`candidate_split_hash` 因为掺了年份而不相等,`askedDiscriminatorKeys` 的 split-hash 去重拦不住。
## 3. 决策记录
1. **一道题只能绑定一条探针,且必须是 followup 指定的那条**`pickProbe` 的匹配顺序改为 `probe_id``semantic_key` → 种类兜底。找不到 `semantic_key` 对应探针时不得退回"同种类第一条",改为返回 `null`(让上游按无探针路径走,不出卡),并在进度记录里说明这种情况是否在测试中出现过。
2. **质量探针只对有"体验词表"的事件种类发**:学业域仅 `education_start``education_change``education_interruption` 可发("如愿 / 调剂 / 失常"对这三类都有意义);`education_completion` 与其它 kind **不发**质量探针,而不是新造一套"毕业顺不顺"的模板。理由:产品要求"多余入口宁可删除也不修";毕业是否顺利本身对 D24 切分没有独立证据价值(与入学事件是同一切分)。
3. **同域同分组只发一条**`_quality_distinguish_probes` 内对 `(domain, frozenset(yes_times), frozenset(no_times))` 去重,保留时间最早(或 `information_gain` 最高、并列取最早)的一条;被去掉的事件不计入 `MAX_QUALITY_DISTINGUISH_PROBES` 名额。
4. **已经答过的错绑答案不追溯修正**。已有案例的历史轮次不回写;只保证新的轮次不再出错。若要为受影响案例做补救,另开任务书。
5. 本任务书不推翻 BUG-389/390/391/392/393 的任何防复发措施;BUG-390 的"质量探针只对学业发出、不得把高考题干套到事业/感情"仍然成立,本次是把它进一步收紧到学业内的特定 kind。
## 4. 硬红线
- 不得改 `QUALITY_DISTINGUISH_OPTIONS` 四选项的文案与 `answer_class` 映射(BUG-390 / 四选项合同 `tests/test_probe_question_contract.py``frontend/tests/rectification-probe-question-contract.test.ts`)。
- 不得新增第二套"毕业体验"模板或选项。
- 不得改 `candidate_split_hash` 的算法(其它模块靠它做 asked-key 去重与回执投影);去重在 `_quality_distinguish_probes` 内部按分组集合做。
- `pickProbe` 改动不得影响 `varga_style` / `existence` 探针的现有选取(`rectification-choice-card.test.ts` 既有断言原样通过;如需改断言写"原值 / 新值 / 原因")。
- 任务书、进度记录、Bug 历史、测试 fixture 一律用抽象年份与虚构事件;不得出现真实 case_id / session_id / 出生候选分钟。
## 5. 任务分解
### 5.1 `pickProbe` 按键选探针(前端)
-`choice-card.ts::pickProbe`:顺序 `followup.probe_id``ChoiceCardFollowup` 需补 `probe_id?: string`,由 `method-followup.ts` 的 followup 透传,见其 L172 已有字段)→ `followup.semantic_key` → 无键时才按 `choice_kind` 兜底;有键但找不到 → `null`
- 验收:`frontend/tests/rectification-choice-card.test.ts` 新增用例——同域两条 `known_event_quality` 探针(不同 `semantic_key` / `target_evidence_id` / `display_date_label`,相同 expected_outcomes),followup 指向第二条时,`buildChoiceFrame``question_id`、题干日期标签、`user_meaning` 全部来自第二条;反向再断言指向第一条时不受影响。
### 5.2 质量探针按事件 kind 发(引擎)
- `_quality_distinguish_probes` 只对 `kind ∈ {education_start, education_change, education_interruption}` 的学业事件生成;`education_completion` 及其它 kind 跳过。事件记录里的 kind 字段名以 `contracts.py::EVENT_KINDS` 为准,不要凭 `domain` 猜。
- 验收:`tests/test_rectification_event_probes.py` 新增:同域一条 `education_start` + 一条 `education_completion` → 只产出一条质量探针且 `target_evidence_id` 指向入学;单独一条 `education_completion` → 零条质量探针,且该事件仍参与普通时间探针(不得因此少问它本身)。
### 5.3 同域同分组去重(引擎)
- 在 5.2 之后再做:同域两条可发事件(如 `education_start` + `education_change`)若 yes/no 集合相同,只保留一条。
- 验收:同文件新增用例,两条 `education_start` 类事件、相同分组 → 一条探针;不同分组 → 两条(`MAX_QUALITY_DISTINGUISH_PROBES` 上限不变)。
### 5.4 记录
- `docs/BUG_HISTORY.md` 新增 BUG-540(前端错绑)、BUG-541(引擎 kind 与去重);关联 BUG-390。
- `docs/tasks/PROGRESS-rectification-quality-probe-dedupe-20260904.md``CHANGELOG.md` 一条。
- `docs/testing/rectification-quality-probe-dedupe-20260904.md`:真实环境清单——新建案例录入"入学 + 毕业"两条学业事件后,候选区分阶段最多出现一次"上大学"题,且题干年份 = 入学年份。
## 6. 让步顺序
5.1 > 5.2 > 5.3 > 5.4。5.3 若与既有 `information_gain` 排序测试冲突,允许先只做 5.1 + 5.2 并把 5.3 写进 `BLOCKED.md`
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-quality-probe-dedupe-20260904 .worktrees/rectification-quality-probe-dedupe-20260904 origin/staging
cd .worktrees/rectification-quality-probe-dedupe-20260904
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
grep -o "BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | sort -u | tail -1
.venv/bin/python -m pytest tests/test_rectification_event_probes.py tests/test_probe_question_contract.py -q
cd frontend && npx tsx --test tests/rectification-choice-card.test.ts tests/rectification-probe-question-contract.test.ts
```
收尾:`tsc --noEmit``npm run lint``npm test`(与 `origin/staging` 基线失败清单逐条比对)、`.venv/bin/python scripts/run_quality_gate.py --profile quick`
@@ -0,0 +1,27 @@
# Staging 人肉复核 · 采用卡位置与交付旁白(2026-09-05)
给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。
对应 BUG-544、BUG-545。测之前先做第 0 条。
## 0. 确认测的是新版本
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
## 1. 时间卡跟在「这次的结果」那句下面(P0 · BUG-544)
1. 把一场校正问到助手给出这次的结果。
2. 看**最新**那条助手消息下面。
- ✅ 预期:旁白以「我按你说的经历认真分析过了,下面是这次的结果。」收尾;`当前可能的出生时间` 卡紧挨着这句,能点「采用此时间」。
- ❌ 失败:卡出现在更早的采集题或区分题下面,最新旁白下面没有卡。
## 2. 采集题还在问时不要出采用卡(P0 · BUG-544)
若过程中还在问「工作上呢,还记得哪年入职…」这类口述题:
- ✅ 预期:这题下面没有采用卡;答完并出采用旁白后,卡才出现在最新旁白下。
## 3. 旁白像对人说话(P1 · BUG-545)
采用旁白:
- ✅ 预期:不出现「当前候选」或「先用着」。收尾是「我按你说的经历认真分析过了,下面是这次的结果。」单分钟只说一遍钟点(例如「眼下更站得住的是 05:15」),不把同一时刻先当范围再当代表分钟。仍有「不是已确认的唯一出生分钟」。
- ❌ 失败:出现「剩下的题分不开当前候选」、「范围是 05:15,代表分钟 05:15」,或「可以先用着」。
@@ -12,7 +12,7 @@
1. 新开一个生时校正会话,按平常走完区分题。
2. **点选最后一道区分题**(不要用采集题「没有」代替)。
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾。
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「我按你说的经历认真分析过了,下面是这次的结果。」收尾。
- ❌ 改造前(BUG-521):点选入口旁白仍是模板句,Agent 没上场。
采集题路径仍要能走:
@@ -0,0 +1,28 @@
# Staging 人肉复核 · 学业质量探针去重(2026-09-04
给产品负责人。实现者无法在真实校正会话里点选。测之前先做第 0 条。
本单对应 BUG-540、BUG-541。不要把真实案例里的年份、事件或候选分钟写进任何记录。
## 0. 确认测的是新版本
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
## 1. 入学 + 毕业最多一道「上大学」发挥题(P0 · BUG-540 / BUG-541
1. 新建生时校正。
2. 采集阶段录入两条学业事件:一条入学、一条毕业(相隔几年即可),并补齐其它领域,走到**候选区分**(出现发挥/体验点选卡的那一段,不是采用后核对)。
- ✅ 预期:最多出现一次「那次上大学,更接近如愿、将就调剂、发挥失常还是说不清」。题干年份 = **入学**年份,不是毕业年份。
- ❌ 改造前:同一张入学发挥卡连出两次;第二次计分其实绑在毕业事件上。
## 2. 题干与选项对得上当前这一问(P0 · BUG-540)
若第 1 条出现了发挥卡:
- ✅ 预期:日期标签、题干、「上大学」这句都指向同一年(入学那年)。不会一边写入学年、一边打分到毕业。
- ❌ 改造前:卡面是入学题,后台 `probe_id` 却是毕业。
## 3. 只有毕业、没有入学时(P1 · BUG-541)
新案例只记毕业、不记入学,走到区分阶段:
- ✅ 预期:不会出现套在毕业上的「上大学 / 调剂 / 发挥失常」题。仍可问其它带年份的存在性区分题。
- ❌ 改造前:毕业也会被写成高考发挥题。
+2 -2
View File
@@ -136,7 +136,7 @@ is read through an external store so a change in one tab reaches the others.
### Font stacks
- Display: `"Tiempos Headline", "Songti SC", "STSong", "Noto Serif CJK SC", Georgia, serif`. The licensed Copernicus/Tiempos files are unavailable; the Chinese Song serif stack is the declared production substitute.
- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/google` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/local` from `src/app/fonts/InterVariable-latin.woff2` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter, and image builds do not call fonts.googleapis.com. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`, exposed as `--font-mono`.
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand.
@@ -473,7 +473,7 @@ or user IDs.
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出采用卡。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
+2 -1
View File
@@ -30,7 +30,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。 | 直接点下面的选项就行,打字回答也一样算数。 | 内部计分规则不进聊天。 |
| 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 |
| 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 |
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。卡片必须就在这句下面。 |
| 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
Inter latin variable (wght axis)
File: InterVariable-latin.woff2
Taken from @fontsource-variable/inter@5.3.0 files/inter-latin-wght-normal.woff2
Upstream: https://github.com/rsms/inter
License: SIL Open Font License 1.1 (OFL.txt)
+6 -3
View File
@@ -1,14 +1,17 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { themePreferenceBootScript } from "@/lib/theme-preference";
const inter = Inter({
subsets: ["latin"],
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
// the publish image cannot download fonts at build time.
const inter = localFont({
src: "./fonts/InterVariable-latin.woff2",
display: "swap",
variable: "--font-inter",
weight: "100 900",
});
export const metadata: Metadata = {
@@ -95,7 +95,15 @@ import {
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question";
import {
applyLiveCandidateOffer,
copyTextForMessage,
interviewQuestionBlocksAdoptOffer,
parseTurnQuestion,
persistedOfferFromTurn,
questionIsAnswered,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
type PersistedTurn = Readonly<{
@@ -264,9 +272,11 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[])
return {
...message,
question: question ?? undefined,
candidateOffer: next?.offerResultId
? { resultId: next.offerResultId }
: message.candidateOffer,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,
true,
),
};
});
}
@@ -609,25 +619,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!canOffer && !adopted) return;
const resultId = candidateResult.resultId;
queueMicrotask(() => {
setMessages((current) => {
if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current;
if (adopted && current.some((message) => message.candidateOffer)) {
return current.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message
));
}
if (!canOffer || adopted) return current;
const owner = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.failed
));
if (!owner) return current;
return current.map((message) => (
message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message
));
});
setMessages((current) => applyLiveCandidateOffer(current, {
resultId,
canOffer,
adopted,
}));
});
}, [candidateResult]);
@@ -1375,13 +1371,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& regeneratingMessageKey === null,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey;
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const selectionCardMessageKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
const showSelectionCards = Boolean(
candidateResult
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime)),
&& (canOfferCards || Boolean(candidateResult.selectedTime))
&& !messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime))
)),
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
+60 -2
View File
@@ -46,6 +46,7 @@ export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const;
export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const;
export const CHART_IDS = ["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
export const REPORT_DOCUMENT_V2_ACTION_NOTES_MAX = 24;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id");
@@ -281,7 +282,7 @@ export const reportDocumentV2Schema = z.strictObject({
executiveSummary: executiveSummaryV2Schema,
natalFoundation: natalFoundationSchema,
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(24),
actionNotes: z.array(actionNoteSchema).min(1).max(REPORT_DOCUMENT_V2_ACTION_NOTES_MAX),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
@@ -313,6 +314,63 @@ export type ReportDocumentParseError = Readonly<{
code: string;
}>;
/**
* Map a guard message to a field path + kind. The message itself is kept on
* the typed error for tests; logs must use only path and code (no values).
*/
export function classifyReportDocumentGuardError(message: string): ReportDocumentParseError {
const forbidden = /^(.*?): forbidden content ([a-z_,]+)$/.exec(message);
if (forbidden) {
const kinds = forbidden[2].split(",").filter((kind) => /^[a-z_]+$/.test(kind));
return {
path: forbidden[1] || "(guard)",
message,
code: kinds[0] ? `forbidden_${kinds[0]}` : "forbidden_content",
};
}
const dateClaim = /^(.*?): date claim requires evidenceRefs$/.exec(message);
if (dateClaim) {
return { path: dateClaim[1] || "(guard)", message, code: "unsupported_date" };
}
const blocked = /^(.*?): blocked section contains deterministic prediction$/.exec(message);
if (blocked) {
return { path: blocked[1] || "(guard)", message, code: "blocked_deterministic" };
}
if (
message.startsWith("requestedThemes must contain ")
|| message.startsWith("requested theme ")
|| message.startsWith("thematic section covers unrequested theme ")
|| message.startsWith("blocked disclosure covers unrequested theme ")
) {
return { path: "requestedThemes", message, code: "theme_coverage" };
}
if (message.includes(" requires structured ") && message.includes(" chart data")) {
return { path: "thematicNarrative", message, code: "missing_required_chart" };
}
if (
message.startsWith("charts must ")
|| message.startsWith("duplicate chart id ")
|| message.startsWith("D1 chart ")
|| / chart contains duplicate house numbers$/.test(message)
) {
return { path: "charts", message, code: "chart_set" };
}
if (message.startsWith("evidence id ")) {
return { path: "evidenceAppendix", message, code: "duplicate_evidence_id" };
}
const dangling = /^evidenceRefs: unknown evidence id (.+):([^:]+)$/.exec(message);
if (dangling) {
return { path: dangling[1], message, code: "dangling_ref" };
}
if (message.startsWith("evidenceRefs:")) {
return { path: "evidenceRefs", message, code: "dangling_ref" };
}
if (message.startsWith("serialized document")) {
return { path: "(root)", message, code: "document_too_large" };
}
return { path: "(guard)", message, code: "guard" };
}
export class ReportDocumentValidationError extends Error {
readonly errors: readonly ReportDocumentParseError[];
@@ -602,7 +660,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
if (guardErrors.length > 0) {
return {
ok: false,
errors: guardErrors.map((message) => ({ path: "(guard)", message, code: "guard" })),
errors: guardErrors.map((message) => classifyReportDocumentGuardError(message)),
};
}
return { ok: true, document };
@@ -4,7 +4,9 @@ import {
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import {
BLOCKED_DETERMINISTIC_PHRASES,
CHART_IDS,
REPORT_DOCUMENT_V2_ACTION_NOTES_MAX,
REQUIRED_THEME_CHARTS,
type ClaimStatus,
type EvidenceAppendix,
@@ -2773,6 +2775,9 @@ export function assembleReportDocumentV2(
evidenceRefs: [...executiveRefs],
});
}
if (actionNotes.length > REPORT_DOCUMENT_V2_ACTION_NOTES_MAX) {
actionNotes.length = REPORT_DOCUMENT_V2_ACTION_NOTES_MAX;
}
const usedEvidenceRefs = new Set<string>([
...executiveRefs,
@@ -2918,6 +2923,28 @@ export function redactDeterministicSentences(
const BLOCKED_SECTION_CAVEAT = "该部分证据受限,已按确定性边界降级,仅保留方向性描述。";
const BLOCKED_DETERMINISTIC_PATTERNS = BLOCKED_DETERMINISTIC_PHRASES.map(
(phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"),
);
function scrubBlockedDeterministicStrings(target: JsonRecord, keys: readonly string[]): void {
for (const key of keys) {
const value = target[key];
if (typeof value === "string") {
const redacted = redactDeterministicSentences(value, BLOCKED_DETERMINISTIC_PATTERNS);
target[key] = redacted.text.length > 0 ? redacted.text : BLOCKED_SECTION_CAVEAT;
} else if (Array.isArray(value)) {
target[key] = value
.map((item) => (
typeof item === "string"
? redactDeterministicSentences(item, BLOCKED_DETERMINISTIC_PATTERNS).text
: item
))
.filter((item) => typeof item !== "string" || item.length > 0);
}
}
}
type GuardSection = {
id: string;
narrative: string;
@@ -3102,6 +3129,7 @@ export function applyReportGuard<D>(
const caveats = stringArray(target.caveats);
if (!caveats.includes(BLOCKED_SECTION_CAVEAT)) caveats.push(BLOCKED_SECTION_CAVEAT);
target.caveats = caveats;
scrubBlockedDeterministicStrings(target, ["narrative", "actions", "caveats", "title"]);
}
}
const summaryRedacted = redactDeterministicSentences(
@@ -3112,6 +3140,7 @@ export function applyReportGuard<D>(
if (summaryRedacted.removedCount > 0) {
summary.summary = summaryRedacted.text;
summary.overallClaimStatus = "blocked";
scrubBlockedDeterministicStrings(summary, ["headline", "summary", "priorities"]);
}
if (Array.isArray(summary.priorities)) {
const keptPriorities = summary.priorities
@@ -3144,6 +3173,7 @@ export function applyReportGuard<D>(
if (target.claimStatus === "blocked") blockedSectionCount += 1;
const finalStatus = target.claimStatus as string;
if (finalStatus === "blocked") {
scrubBlockedDeterministicStrings(target, ["narrative", "actions", "caveats", "title"]);
const narrativeText = typeof target.narrative === "string" ? target.narrative : "";
const blockedClaims = findForbiddenDeterministicClaims(narrativeText);
const hardDomain = blockedClaims.find((claim) => claim.domain !== "timing");
@@ -3162,6 +3192,24 @@ export function applyReportGuard<D>(
}
if (summary && (readModel.sections.length === 0 || blockedSectionCount === readModel.sections.length)) {
summary.overallClaimStatus = "blocked";
scrubBlockedDeterministicStrings(summary, ["headline", "summary", "priorities"]);
}
const currentPhase = record(next.currentPhase);
if (currentPhase) {
const timing = narrative.find((item) => {
const row = record(item);
return text(row?.theme) === "timing" || text(row?.id) === "theme-timing";
});
const timingRow = record(timing);
if (timingRow) {
if (typeof timingRow.narrative === "string") currentPhase.narrative = timingRow.narrative;
if (typeof timingRow.claimStatus === "string") currentPhase.claimStatus = timingRow.claimStatus;
if (Array.isArray(timingRow.caveats)) currentPhase.caveats = [...timingRow.caveats];
if (currentPhase.claimStatus === "blocked") {
scrubBlockedDeterministicStrings(currentPhase, ["narrative", "timingNotes", "caveats", "title", "phaseLabel"]);
}
}
}
return { ok: true, document: next as D };
@@ -71,7 +71,7 @@ export const RECTIFICATION_USER_COPY = {
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "可以从下面选一个先用着。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
@@ -170,6 +170,12 @@ export function nonConvergingRangeNarration(
}
if (rangeText && representative) {
const collapsed = Boolean(range && range[0] === range[1]);
if (collapsed) {
const head = progress ?? `眼下更站得住的是 ${representative}`;
const body = `${head}${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}` : ``;
const body = `${head}${representativeClause}${boundaryCopy}`;
@@ -189,7 +195,9 @@ export function nonConvergingRangeNarration(
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`;
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
const INSTRUCTION_TONE_SENTENCE = /不得|请写成/;
@@ -23,7 +23,7 @@ export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出
2 4
post_adopt_verification
使
`;
`;
export type AdoptNarrationOutcome =
| "agent"
@@ -113,13 +113,13 @@ function stopFactsFromDropped(
if (representative && runnerUp && representative !== runnerUp) {
facts.push({
kind: "indistinguishable",
label: `剩下的题分不开 ${representative}${runnerUp}`,
label: `再问下去也分不开 ${representative}${runnerUp}`,
count: Math.max(1, noSplit),
});
} else if (noSplit > 0) {
facts.push({
kind: "indistinguishable",
label: "剩下的题分不开当前候选",
label: "再问下去也分不出更准的时间了",
count: noSplit,
});
}
@@ -224,10 +224,11 @@ export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute) {
return `剩下的问题分不开 ${facts.representative_minute}${facts.runner_up_minute}`;
if (split && facts.representative_minute && facts.runner_up_minute
&& facts.representative_minute !== facts.runner_up_minute) {
return `再问下去也分不开 ${facts.representative_minute}${facts.runner_up_minute}`;
}
if (split) return `${split.label}`;
if (split) return `${split.label.replace(/。?$/, "")}`;
return null;
}
@@ -11,6 +11,9 @@ export const MACHINE_VOICE_LEXICON = [
"当前区分题",
"接下来请点选下面这一问",
"可以从下面的时间里选一个采用",
"可以从下面选一个先用着",
"下面的时间可以先用着",
"剩下的题分不开当前候选",
"我没能确定这句话是否在回答当前问题",
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
@@ -100,6 +100,7 @@ export type ChoiceCardFollowup = Readonly<{
choice_kind?: EventProbeChoiceKind;
style_options?: readonly EventProbeStyleOption[];
semantic_key?: string;
probe_id?: string;
}>;
export type ChoiceCardEvidence = Readonly<{
@@ -230,12 +231,28 @@ function followupDomain(followup: ChoiceCardFollowup): string | null {
return THEME_DOMAIN[followup.ask_theme] ?? null;
}
function probeMatchesId(item: DiscriminatingEventProbe, probeId: string): boolean {
return item.semantic_key === probeId;
}
function pickProbe(
probes: readonly DiscriminatingEventProbe[] | undefined,
domain: string | null,
followup?: ChoiceCardFollowup,
): DiscriminatingEventProbe | null {
if (!probes?.length) return null;
const probeId = followup?.probe_id?.trim() ?? "";
const semanticKey = followup?.semantic_key?.trim() ?? "";
const hasKey = Boolean(probeId || semanticKey);
if (probeId) {
const byId = probes.find((item) => probeMatchesId(item, probeId));
if (byId) return byId;
}
if (semanticKey) {
const keyed = probes.find((item) => item.semantic_key === semanticKey);
if (keyed) return keyed;
}
if (hasKey) return null;
const inDomain = domain ? probes.filter((item) => item.domain === domain) : [...probes];
const pool = inDomain.length > 0 ? inDomain : probes;
if (followup?.choice_kind === "event_quality") {
@@ -244,10 +261,6 @@ function pickProbe(
);
if (quality) return quality;
}
if (followup?.semantic_key) {
const keyed = pool.find((item) => item.semantic_key === followup.semantic_key);
if (keyed) return keyed;
}
return pool[0] ?? probes[0] ?? null;
}
@@ -386,8 +399,10 @@ export function buildChoiceFrame(
const skipQuestion = skipThisProbe
|| followup.intent === "out_of_sample_check"
|| followup.source === "oos_blind";
const probeKey = followup.semantic_key?.trim() || followup.probe_id?.trim() || "";
const questionBase = `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`;
return {
question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`,
question_id: probeKey ? `${questionBase}:${probeKey}` : questionBase,
method_id: followup.method_id,
period: periodFor(input.evidence, domain, input.probes, input.birthDate, followup),
prompt: hypothesis.prompt,
@@ -120,6 +120,89 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
});
}
export function interviewQuestionBlocksAdoptOffer(
question: TurnQuestion | null | undefined,
adopted: boolean,
): boolean {
if (adopted) return false;
if (!question || questionIsAnswered(question)) return false;
return question.kind === "collect_spoken" || question.kind === "choice";
}
export type CandidateOfferAnchor = {
renderKey: string;
role: string;
state?: string;
text?: string | null;
failed?: boolean;
question?: TurnQuestion | null;
candidateOffer?: Readonly<{ resultId: string }>;
};
function isSettledAssistant(message: CandidateOfferAnchor): boolean {
if (message.role !== "assistant" || message.failed) return false;
if (message.state && message.state !== "settled") return false;
return Boolean(message.text);
}
export function applyLiveCandidateOffer<T extends CandidateOfferAnchor>(
messages: readonly T[],
input: { resultId: string; canOffer: boolean; adopted: boolean },
): T[] {
if (input.adopted) {
if (!messages.some((message) => message.candidateOffer)) return [...messages];
return messages.map((message) => (
message.candidateOffer
? { ...message, candidateOffer: { resultId: input.resultId } }
: message
));
}
if (!input.canOffer) return [...messages];
const liveInterview = messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
));
if (liveInterview) {
return messages.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
));
}
const owner = [...messages].reverse().find((message) => (
isSettledAssistant(message)
&& !interviewQuestionBlocksAdoptOffer(message.question, false)
));
if (!owner) {
return messages.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
));
}
return messages.map((message) => ({
...message,
candidateOffer: message.renderKey === owner.renderKey
? { resultId: input.resultId }
: undefined,
}));
}
export function persistedOfferFromTurn(
offerResultId: string | null | undefined,
previous: Readonly<{ resultId: string }> | undefined,
turnHydrated: boolean,
): Readonly<{ resultId: string }> | undefined {
if (typeof offerResultId === "string" && offerResultId.length > 0) {
return { resultId: offerResultId };
}
if (turnHydrated) return undefined;
return previous;
}
function assistantCanOwnAdoptOffer(
turn: { role?: string; question?: TurnQuestion | null },
adopted: boolean,
): boolean {
return turn.role === "assistant"
&& !interviewQuestionBlocksAdoptOffer(turn.question, adopted);
}
export function attachOfferResultToTurns<T extends {
id: string;
role: string;
@@ -137,20 +220,21 @@ export function attachOfferResultToTurns<T extends {
if (!offered || !resultId) {
return turns.map((turn) => ({ ...turn, offer_result_id: null }));
}
const adopted = Boolean(input.acceptedTime);
const firstVerify = turns.findIndex((turn) => (
turn.role === "assistant" && turn.question?.kind === "reverse_verify"
));
const searchUntil = firstVerify >= 0 ? firstVerify : turns.length;
let ownerIndex = -1;
for (let index = searchUntil - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) {
ownerIndex = index;
break;
}
}
if (ownerIndex < 0) {
for (let index = turns.length - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) {
ownerIndex = index;
break;
}
@@ -70,6 +70,18 @@ test("accident-case after copy is rebuilt from the shared module", () => {
assert.ok(containsBoundarySemantics(after.deliveryAdopt));
});
test("a single-minute range is not spoken twice as range and representative", () => {
const text = nonConvergingRangeNarration({
credibleRange: ["05:15", "05:15"],
representativeTime: "05:15",
});
assert.match(text, /眼下更站得住的是 05:15/);
assert.doesNotMatch(text, /代表分钟 05:15/);
assert.doesNotMatch(text, /更站得住的范围是 05:15/);
assert.equal(containsBoundarySemantics(text), true);
assert.equal(RECTIFICATION_USER_COPY.adoptCue, "我按你说的经历认真分析过了,下面是这次的结果。");
});
test("default range narration stays semantic even without an opening window", () => {
const text = nonConvergingRangeNarration({
credibleRange: ["04:47", "04:53"],
@@ -12,6 +12,7 @@ import {
findChartSetViolations,
findDanglingEvidenceRefs,
findDuplicateEvidenceIds,
classifyReportDocumentGuardError,
CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION,
LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION,
REPORT_DOCUMENT_SCHEMA_VERSION,
@@ -404,3 +405,28 @@ test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () =
assert.equal(findForbiddenContent(ordinary.actionNotes[0].note).length, 0);
assert.equal(safeParseReportDocument(ordinary).ok, true);
});
test("guard parse errors expose field path and kind without the offending value", () => {
const missing = cloneV2();
missing.blockedConflictDisclosure = missing.blockedConflictDisclosure.filter(
(section) => section.theme !== "career",
);
const coverage = safeParseReportDocument(missing);
assert.equal(coverage.ok, false);
assert.ok(coverage.errors.some((error) => (
error.path === "requestedThemes" && error.code === "theme_coverage"
)));
const medical = cloneV2();
medical.actionNotes[0].note = "你已经患有糖尿病。";
const forbidden = safeParseReportDocument(medical);
assert.equal(forbidden.ok, false);
assert.ok(forbidden.errors.some((error) => (
error.path === "actionNotes[0].note" && error.code === "forbidden_medical_diagnosis"
)));
assert.ok(forbidden.errors.every((error) => !error.path.includes("糖尿病")));
assert.equal(
classifyReportDocumentGuardError("thematicNarrative[1]: blocked section contains deterministic prediction").code,
"blocked_deterministic",
);
});
@@ -13,7 +13,7 @@ import {
type ReportEvidenceBundleV2,
type TechniqueExecutionReceipt,
} from "../src/lib/report-evidence-bundle-v2.ts";
import { CHART_IDS } from "../src/lib/personal-report-contract.ts";
import { CHART_IDS, REPORT_DOCUMENT_V2_ACTION_NOTES_MAX } from "../src/lib/personal-report-contract.ts";
import {
classifyFinalParseInnerReason,
classifyReportSchemaInnerReason,
@@ -392,6 +392,29 @@ test("five-theme sectioned pipeline with the full CHART_IDS set finishes READY",
assert.deepEqual(sectionCalls, ["career", "health", "marriage", "timing", "wealth"]);
});
test("five-theme assemble keeps actionNotes within the document cap", async () => {
const bundle = makeBundle({
themes: standardFiveThemes,
charts: CHART_IDS.map((id, offset) => chart(id, offset)),
});
const inner = sectionedAgent({});
const agent: ReportAgentPort = {
...inner,
async generateSection(sectionBundle, section, completedTitles, options) {
const output = await inner.generateSection!(sectionBundle, section, completedTitles, options);
return {
...output,
actions: Array.from({ length: 6 }, (_, index) => (
`围绕${output.title}记录可验证的现实反馈 ${index + 1}`
)),
};
},
};
const document = readyV2(await runSectioned(bundle, agent, inMemorySectionService()));
assert.equal(document.actionNotes.length, REPORT_DOCUMENT_V2_ACTION_NOTES_MAX);
assert.equal(document.blockedConflictDisclosure.length, 0);
});
test("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => {
const bundle = makeBundle({
themes: [{
@@ -572,7 +572,7 @@ test("guard redacts precise timing and downgrades the section when timing is blo
id: "career",
theme: "career",
title: "事业",
narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。",
narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。命主一定会升职。",
actions: ["2027年3月跳槽"],
caveats: [],
claimStatus: "single_system_inference",
@@ -587,6 +587,7 @@ test("guard redacts precise timing and downgrades the section when timing is blo
const section = (guarded.document as unknown as { thematicNarrative: { narrative: string; claimStatus: string; caveats: string[] }[] })
.thematicNarrative[0];
assert.doesNotMatch(section.narrative, /2027年3月/);
assert.doesNotMatch(section.narrative, /一定会/);
assert.equal(section.claimStatus, "blocked");
assert.ok(section.caveats.some((caveat) => caveat.includes("确定性边界")));
});
@@ -615,7 +615,7 @@ function adoptAccounting(dossier: DecisionDossier, activeFocus?: Record<string,
function assertAdoptTemplate(text: string) {
assert.match(text, /分不开 05:00 和 05:06/);
assert.match(text, /可以从下面选一个先用着/);
assert.match(text, /我按你说的经历认真分析过了/);
assert.doesNotMatch(text, /继续往下收/);
}
@@ -699,7 +699,7 @@ test("adopt narration agent keeps in-fact copy and fail-closes the rest", async
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06,代表分钟 05:00。采用后会用 2016 年学业经历核对。";
const valid = await createAdoptNarrationWriter({
@@ -894,7 +894,7 @@ test("adopt narration diagnostics distinguish agent, validation, error, and not-
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const agent = await deliverAdoptNarration({
@@ -936,7 +936,7 @@ test("adopt narration times out to the template without throwing", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const timed = await deliverAdoptNarration({
facts,
fallback,
@@ -951,7 +951,7 @@ test("adopt narration does not leave an active timeout after the model returns",
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const source = readFileSync(
new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url),
@@ -565,9 +565,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
// 锚在 latestSettledAssistant。采集题没有 AD 卡,所以 collect_evidence 会出采用卡;
// 采用后 busy 把卡藏掉,新消息又把卡带走。
// 新:canShowRectificationSelectionCards(含 session_outcome 门)+ candidateOffer 所有权;
// 已采用后不再把卡锚到最新消息。核对题可与原消息上的结算卡并存
// 未采用时卡跟着最新采用旁白走,采集题还在问则不出卡;已采用后不再把卡锚到最新消息。
// 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /applyLiveCandidateOffer/);
assert.match(chat, /persistedOfferFromTurn/);
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/);
assert.doesNotMatch(
@@ -608,11 +608,13 @@ test("last structured choice emits the adoption range and persists the same narr
assert.equal(applied.nextAction.type, "ready_to_adopt");
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, /05:\d{2}/);
assert.match(applied.narration, /代表分钟/);
// 旧:/代表分钟/ 锁「范围是 X,代表分钟 X」。单分钟区间会把同一时刻念两遍。
// 新:单分钟说「眼下更站得住的是 HH:MM」;跨分钟范围仍可说代表分钟。
assert.match(applied.narration, /眼下更站得住的是|代表分钟/);
assert.equal(containsBoundarySemantics(applied.narration), true);
assert.match(applied.narration, /可以从下面选一个先用着/);
assert.match(applied.narration, /我按你说的经历认真分析过了/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面选一个先用着/);
assert.match(String(turn?.args.p_assistant_message ?? ""), /我按你说的经历认真分析过了/);
});
test("clicking A applies the choice without invoking a language model", async () => {
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyLiveCandidateOffer,
attachOfferResultToTurns,
persistedOfferFromTurn,
type TurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
function collect(status: "active" | "resolved"): TurnQuestion {
return {
focus_id: "focus-career",
question_id: "collect:career:collect_method_evidence",
kind: "collect_spoken",
prompt: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
options: null,
status,
answer_option: null,
probe_id: null,
};
}
test("adopt offer skips an unanswered collect question and lands on the later narration", () => {
const attached = attachOfferResultToTurns([
{ id: "collect-turn", role: "assistant", question: collect("active") },
{ id: "user-turn", role: "user", question: null },
{ id: "adopt-turn", role: "assistant", question: null },
], {
resultId: "result-1",
canAdopt: true,
acceptedTime: null,
});
assert.equal(attached[0]?.offer_result_id, null);
assert.equal(attached[2]?.offer_result_id, "result-1");
});
test("adopt offer is not pinned onto a live collect question even if it is the only assistant turn", () => {
const attached = attachOfferResultToTurns([
{ id: "collect-turn", role: "assistant", question: collect("active") },
], {
resultId: "result-1",
canAdopt: true,
acceptedTime: null,
});
assert.equal(attached[0]?.offer_result_id, null);
});
test("live offer moves off a collect message once the adopt narration exists", () => {
const next = applyLiveCandidateOffer([
{
renderKey: "collect",
role: "assistant",
state: "settled",
text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
question: collect("resolved"),
candidateOffer: { resultId: "result-1" },
},
{
renderKey: "user",
role: "user",
state: "settled",
text: "2020 年 4 月实习然后 10 月离职",
},
{
renderKey: "adopt",
role: "assistant",
state: "settled",
text: "再问下去也分不出更准的时间了。眼下更站得住的是 05:15。这只是代表性候选,不是已确认的唯一出生分钟。我按你说的经历认真分析过了,下面是这次的结果。",
},
], { resultId: "result-1", canOffer: true, adopted: false });
assert.equal(next[0]?.candidateOffer, undefined);
assert.deepEqual(next[2]?.candidateOffer, { resultId: "result-1" });
});
test("live offer stays hidden while an unanswered collect question is still on screen", () => {
const next = applyLiveCandidateOffer([
{
renderKey: "collect",
role: "assistant",
state: "settled",
text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
question: collect("active"),
candidateOffer: { resultId: "result-1" },
},
], { resultId: "result-1", canOffer: true, adopted: false });
assert.equal(next[0]?.candidateOffer, undefined);
});
test("after adopt the offer stays on the original message", () => {
const next = applyLiveCandidateOffer([
{
renderKey: "offer",
role: "assistant",
state: "settled",
text: "我按你说的经历认真分析过了,下面是这次的结果。",
candidateOffer: { resultId: "result-1" },
},
{
renderKey: "verify",
role: "assistant",
state: "settled",
text: "2021 年前后,有没有家人结婚、添丁或住院?",
question: {
focus_id: "verify",
question_id: "reverse_verify:family:score",
kind: "reverse_verify",
prompt: "2021 年前后,有没有家人结婚、添丁或住院?",
options: null,
status: "active",
answer_option: null,
probe_id: null,
},
},
], { resultId: "result-2", canOffer: true, adopted: true });
assert.deepEqual(next[0]?.candidateOffer, { resultId: "result-2" });
assert.equal(next[1]?.candidateOffer, undefined);
});
test("hydrated turns drop a stale client offer when GET says this turn is not the owner", () => {
assert.equal(
persistedOfferFromTurn(null, { resultId: "result-1" }, true),
undefined,
);
assert.deepEqual(
persistedOfferFromTurn("result-2", { resultId: "result-1" }, true),
{ resultId: "result-2" },
);
assert.deepEqual(
persistedOfferFromTurn(null, { resultId: "result-1" }, false),
{ resultId: "result-1" },
);
});
@@ -1666,3 +1666,130 @@ test("GET does not mint a tap card from an out-of-sample spoken collect focus",
});
assert.equal(card, null);
});
const EDUCATION_QUALITY_OPTIONS = [
{ label: "发挥明显失常", answer_class: "yes" },
{ label: "只是将就调剂", answer_class: "weak_yes" },
{ label: "基本如愿录取", answer_class: "no" },
{ label: "当时说不清楚", answer_class: "unsure" },
] as const;
const QUALITY_SPLIT_OUTCOMES = [
{ answer_class: "yes", supports: ["05:00", "05:01"], conflicts: ["05:10", "05:11"] },
{ answer_class: "weak_yes", supports: ["05:00", "05:01"], conflicts: ["05:10", "05:11"] },
{ answer_class: "no", supports: ["05:10", "05:11"], conflicts: ["05:00", "05:01"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
] as const;
function educationQualityProbe(input: {
semanticKey: string;
evidenceId: string;
year: number;
month: number;
meaning: string;
dateLabel: string;
}): DiscriminatingEventProbe {
return {
year: input.year,
month: input.month,
year_label: `${input.year}${input.month} 月前后`,
domain: "education",
event_family: "学业或考试发挥失常、压力特别大",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: input.meaning,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: 0.5,
semantic_key: input.semanticKey,
candidate_split_hash: `${input.semanticKey}:split`,
target_evidence_id: input.evidenceId,
display_date_label: input.dateLabel,
choice_kind: "event_quality",
style_options: EDUCATION_QUALITY_OPTIONS,
expected_outcomes: QUALITY_SPLIT_OUTCOMES,
candidate_ids: ["05:00", "05:01", "05:10", "05:11"],
};
}
test("event_quality cards bind to the followup probe, not the first same-kind probe", () => {
const startProbe = educationQualityProbe({
semanticKey: "education.2014.known_event_quality",
evidenceId: "11111111-1111-4111-8111-111111111111",
year: 2014,
month: 9,
dateLabel: "2014 年 9 月",
meaning: "2014 年 9 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。",
});
const laterProbe = educationQualityProbe({
semanticKey: "education.2018.known_event_quality",
evidenceId: "22222222-2222-4222-8222-222222222222",
year: 2018,
month: 6,
dateLabel: "2018 年 6 月",
meaning: "2018 年 6 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。",
});
const probes = [startProbe, laterProbe];
const laterFrame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
intent: "distinguish_candidates",
choice_kind: "event_quality",
semantic_key: laterProbe.semantic_key,
probe_id: laterProbe.semantic_key,
style_options: EDUCATION_QUALITY_OPTIONS,
}, { probes });
assert.ok(laterFrame);
assert.match(laterFrame.question_id, /education\.2018\.known_event_quality/);
assert.doesNotMatch(laterFrame.question_id, /education\.2014\.known_event_quality/);
assert.match(laterFrame.period, /2018 年 6 月/);
assert.doesNotMatch(laterFrame.period, /2014/);
assert.match(laterFrame.prompt, /2018 年 6 月那次上大学/);
assert.doesNotMatch(laterFrame.prompt, /2014/);
assert.match(laterFrame.why, /2018 年 6 月那次上大学/);
assert.doesNotMatch(laterFrame.why, /2014/);
const startFrame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
intent: "distinguish_candidates",
choice_kind: "event_quality",
semantic_key: startProbe.semantic_key,
probe_id: startProbe.semantic_key,
style_options: EDUCATION_QUALITY_OPTIONS,
}, { probes });
assert.ok(startFrame);
assert.match(startFrame.question_id, /education\.2014\.known_event_quality/);
assert.match(startFrame.period, /2014 年 9 月/);
assert.match(startFrame.prompt, /2014 年 9 月那次上大学/);
assert.match(startFrame.why, /2014 年 9 月那次上大学/);
assert.doesNotMatch(startFrame.prompt, /2018/);
});
test("event_quality followup with a missing probe key does not fall back to another quality probe", () => {
const startProbe = educationQualityProbe({
semanticKey: "education.2014.known_event_quality",
evidenceId: "11111111-1111-4111-8111-111111111111",
year: 2014,
month: 9,
dateLabel: "2014 年 9 月",
meaning: "2014 年 9 月那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。",
});
const frame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
choice_kind: "event_quality",
semantic_key: "education.2018.known_event_quality",
probe_id: "education.2018.known_event_quality",
style_options: EDUCATION_QUALITY_OPTIONS,
}, { probes: [startProbe] });
assert.equal(frame, null);
});
@@ -1124,7 +1124,7 @@ test("live five-evidence case opens adopt instead of occupation collect", async
birthDate: "1997-08-08",
});
assert.equal(persisted.persisted, false);
assert.match(persisted.hostNarration, /可以从下面选一个先用着/);
assert.match(persisted.hostNarration, /我按你说的经历认真分析过了/);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
@@ -425,7 +425,7 @@ test("incident shape 1c: uncovered occupation still opens provisional adopt", as
});
assert.equal(persisted.persisted, false);
assert.equal(activeFocus, null);
assert.match(persisted.hostNarration ?? "", /可以从下面选一个先用着/);
assert.match(persisted.hostNarration ?? "", /我按你说的经历认真分析过了/);
assert.equal(decision.canConfirmExactMinute, false);
assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({
resultId: dossier.latestResult?.resultId,
@@ -463,7 +463,7 @@ test("after-choice persist does not open another collect once dated coverage can
});
assert.equal(next.persisted, false);
assert.equal(writes, 0);
assert.match(next.hostNarration, /可以从下面选一个先用着/);
assert.match(next.hostNarration, /我按你说的经历认真分析过了/);
});
test("incident shape 2: evidence floor still blocks adopt", () => {
@@ -610,7 +610,7 @@ test("delivery B2: adoptable offer produces adopt_representative with zero follo
assert.match(idle.hostNarration ?? "", /05:0005:07/);
assert.match(idle.hostNarration ?? "", /05:00/);
assert.equal(containsBoundarySemantics(idle.hostNarration ?? ""), true);
assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/);
assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/);
});
test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => {
@@ -557,7 +557,7 @@ test("adoptable range offer does not persist another spoken collect", async () =
});
assert.equal(idle.persisted, false);
assert.equal(idle.choiceReady, false);
assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/);
assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/);
assert.match(idle.hostNarration ?? "", /04:4704:53/);
assert.match(idle.hostNarration ?? "", /04:51/);
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { extname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8");
@@ -10,7 +12,9 @@ const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
test("Inter is loaded through next/font and applied as a CSS variable", () => {
assert.match(layout, /import \{ Inter \} from "next\/font\/google"/);
assert.match(layout, /import localFont from "next\/font\/local"/);
assert.doesNotMatch(layout, /from ["']next\/font\/google["']/);
assert.match(layout, /src: "\.\/fonts\/InterVariable-latin\.woff2"/);
assert.match(layout, /display: "swap"/);
assert.match(layout, /variable: "--font-inter"/);
assert.match(layout, /className=\{inter\.variable\}/);
@@ -21,6 +25,31 @@ test("Inter is loaded through next/font and applied as a CSS variable", () => {
);
});
test("Inter is vendored locally so production builds do not fetch Google Fonts", () => {
const font = readFileSync(new URL("../src/app/fonts/InterVariable-latin.woff2", import.meta.url));
assert.equal(Buffer.from(font.subarray(0, 4)).toString(), "wOF2");
assert.ok(font.byteLength > 20_000);
const srcRoot = fileURLToPath(new URL("../src/", import.meta.url));
const stack = [srcRoot];
while (stack.length > 0) {
const dir = stack.pop()!;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (![".ts", ".tsx", ".js", ".jsx", ".css"].includes(extname(entry.name))) continue;
const source = readFileSync(fullPath, "utf8");
assert.doesNotMatch(
source,
/from ["']next\/font\/google["']|fonts\.googleapis\.com/,
`${fullPath} must not fetch Google Fonts at build time`,
);
}
}
});
test("site chrome owns globals.css; admin does not import it", () => {
assert.match(siteStyles, /import "\.\/globals\.css"/);
assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);
+45 -3
View File
@@ -62,6 +62,11 @@ MAX_PROBES_PER_DOMAIN = 3
MAX_BOUNDARY_CANDIDATES_PER_DOMAIN = 8
MIN_BOUNDARY_DAYS = 45
MAX_QUALITY_DISTINGUISH_PROBES = 2
QUALITY_DISTINGUISH_EVENT_KINDS = frozenset({
"education_start",
"education_change",
"education_interruption",
})
ANSWER_PRIOR_TABLE_VERSION = "rectification-answer-priors-v1"
DOMINANT_ANSWER_PRIOR = 0.8
# Conservative population rates, not fitted from product users.
@@ -884,6 +889,43 @@ def _display_date_label(event: dict[str, Any]) -> str:
return f"{year}"
def _event_kind_name(event: dict[str, Any]) -> str:
return str(event.get("event_kind") or event.get("kind") or "")
def _quality_event_allowed(event: dict[str, Any], domain: str) -> bool:
if domain != "education":
return False
return _event_kind_name(event) in QUALITY_DISTINGUISH_EVENT_KINDS
def _quality_split_sets(probe: dict[str, Any]) -> tuple[frozenset[str], frozenset[str]]:
outcomes = probe.get("expected_outcomes") or []
yes = next((row for row in outcomes if isinstance(row, dict) and row.get("answer_class") == "yes"), {})
no = next((row for row in outcomes if isinstance(row, dict) and row.get("answer_class") == "no"), {})
yes_times = yes.get("supports") if isinstance(yes, dict) else ()
no_times = no.get("supports") if isinstance(no, dict) else ()
return frozenset(str(item) for item in (yes_times or ())), frozenset(str(item) for item in (no_times or ()))
def _quality_probe_rank(probe: dict[str, Any]) -> tuple[float, int, int]:
year = int(probe["year"]) if isinstance(probe.get("year"), int) else 9999
month = int(probe["month"]) if isinstance(probe.get("month"), int) else 12
return (-float(probe.get("information_gain") or 0), year, month)
def _select_quality_distinguish_rows(candidates: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
best: dict[tuple[str, frozenset[str], frozenset[str]], dict[str, Any]] = {}
for probe in candidates:
key = (str(probe.get("domain") or ""), *_quality_split_sets(probe))
current = best.get(key)
if current is None or _quality_probe_rank(probe) < _quality_probe_rank(current):
best[key] = probe
selected = list(best.values())
selected.sort(key=_quality_probe_rank)
return selected[:MAX_QUALITY_DISTINGUISH_PROBES]
def _quality_user_meaning(event: dict[str, Any], domain: str) -> str:
label = _display_date_label(event)
if domain == "education":
@@ -918,6 +960,8 @@ def _quality_distinguish_probes(
layer = DOMAIN_QUALITY_LAYER.get(domain)
if not event_id or year is None or layer is None or domain not in DOMAIN_CATALOG:
continue
if not _quality_event_allowed(event, domain):
continue
if event_id in holdout_ids or f"{domain}:{year}" in holdout_keys:
continue
if _quality_encoded(event, domain):
@@ -989,9 +1033,7 @@ def _quality_distinguish_probes(
if distinguish_contract_errors(probe):
continue
rows.append(_apply_prior_ranking(probe))
if len(rows) >= MAX_QUALITY_DISTINGUISH_PROBES:
break
return rows
return _select_quality_distinguish_rows(rows)
def _year_activated(rule_ids: Sequence[str]) -> bool:
+161
View File
@@ -9,7 +9,10 @@ from scripts.rectification.event_probes import (
MAX_COLLECTION_PROBES,
MAX_PROBES,
MAX_PROBES_PER_DOMAIN,
MAX_QUALITY_DISTINGUISH_PROBES,
_agent_brief,
_quality_distinguish_probes,
_select_quality_distinguish_rows,
discriminating_event_probes,
event_clarification_probes,
evidence_collection_probes,
@@ -900,5 +903,163 @@ class EventProbesTest(unittest.TestCase):
self.assertEqual({int(item["year"]) for item in relocation}, {2016, 2019, 2022})
def _education_event(event_id: str, kind: str, date: str, summary: str) -> dict:
return {
"id": event_id,
"domain": "education",
"event_kind": kind,
"summary": summary,
"date": date,
"precision": "month",
}
def _d24_split_clusters() -> list[dict]:
return [
{
"representative": {"feature": {"varga_ascendants": {"D24": 1}}},
"times": ["05:00", "05:01"],
},
{
"representative": {"feature": {"varga_ascendants": {"D24": 2}}},
"times": ["05:10", "05:11"],
},
]
def _quality_rows(*events: dict) -> list[dict]:
return _quality_distinguish_probes(
list(events),
_d24_split_clusters(),
set_version="quality-dedupe-set",
holdout_ids=set(),
holdout_keys=set(),
)
class QualityDistinguishDedupeTests(unittest.TestCase):
def test_start_and_completion_emit_one_quality_probe_for_start(self) -> None:
start = _education_event(
"00000000-0000-4000-8000-000000000021",
"education_start",
"2014-09-01",
"入学",
)
completion = _education_event(
"00000000-0000-4000-8000-000000000022",
"education_completion",
"2018-06-01",
"毕业",
)
rows = _quality_rows(start, completion)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["target_evidence_id"], start["id"])
self.assertEqual(rows[0]["year"], 2014)
self.assertNotIn(completion["id"], [item["target_evidence_id"] for item in rows])
def test_completion_alone_emits_no_quality_probe(self) -> None:
completion = _education_event(
"00000000-0000-4000-8000-000000000022",
"education_completion",
"2018-06-01",
"毕业",
)
self.assertEqual(_quality_rows(completion), [])
def test_completion_still_participates_in_ordinary_time_probes(self) -> None:
request = _request(events=[
_education_event(
"00000000-0000-4000-8000-000000000022",
"education_completion",
"2018-06-01",
"毕业",
),
{
"id": "00000000-0000-4000-8000-000000000013",
"domain": "career",
"event_kind": "career_entry",
"summary": "入职",
"date": "2018-07-01",
"precision": "month",
},
{
"id": "00000000-0000-4000-8000-000000000014",
"domain": "relationship",
"event_kind": "relationship_start",
"summary": "相识",
"date": "2021-08-01",
"precision": "month",
},
{
"id": "00000000-0000-4000-8000-000000000015",
"domain": "family",
"event_kind": "family_event",
"summary": "家里添丁",
"date": "2020-01-01",
"precision": "year",
},
])
probes = _probes(request, _multi_layer_window_built(), ["05:00", "05:06", "05:07"], "05:00")
quality = [item for item in probes if item["source"] == "known_event_quality"]
self.assertEqual(quality, [])
self.assertTrue(probes)
self.assertTrue(any(item["source"] in {"dasha_boundary", "dasha_activation"} for item in probes))
def test_same_split_keeps_earliest_eligible_education_event(self) -> None:
start = _education_event(
"00000000-0000-4000-8000-000000000021",
"education_start",
"2014-09-01",
"入学",
)
change = _education_event(
"00000000-0000-4000-8000-000000000023",
"education_change",
"2016-03-01",
"转学",
)
rows = _quality_rows(start, change)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["target_evidence_id"], start["id"])
def test_different_quality_groups_keep_two_up_to_cap(self) -> None:
def stub(year: int, month: int, supports: list[str], conflicts: list[str], evidence_id: str) -> dict:
return {
"domain": "education",
"year": year,
"month": month,
"information_gain": 0.5,
"target_evidence_id": evidence_id,
"expected_outcomes": [
{"answer_class": "yes", "supports": supports, "conflicts": conflicts},
{"answer_class": "no", "supports": conflicts, "conflicts": supports},
],
}
same_split = _select_quality_distinguish_rows([
stub(2014, 9, ["05:00"], ["05:10"], "start"),
stub(2016, 3, ["05:00"], ["05:10"], "change"),
])
self.assertEqual(len(same_split), 1)
self.assertEqual(same_split[0]["target_evidence_id"], "start")
different = _select_quality_distinguish_rows([
stub(2014, 9, ["05:00"], ["05:10"], "start"),
stub(2016, 3, ["05:02"], ["05:12"], "change"),
])
self.assertEqual(len(different), 2)
self.assertEqual(
{item["target_evidence_id"] for item in different},
{"start", "change"},
)
over_cap = _select_quality_distinguish_rows([
stub(2014, 9, ["05:00"], ["05:10"], "a"),
stub(2015, 9, ["05:02"], ["05:12"], "b"),
stub(2016, 9, ["05:04"], ["05:14"], "c"),
])
self.assertEqual(len(over_cap), MAX_QUALITY_DISTINGUISH_PROBES)
if __name__ == "__main__":
unittest.main()