Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 329f8a2109 | |||
| 7c776bdd3a | |||
| 40c623edf6 | |||
| 73c2a1a51e | |||
| 4ef4c4064f | |||
| a68fd01f68 | |||
| a75929c139 |
+13
-1
@@ -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
@@ -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_rejected,accepted 报告仍无 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 2408(SHA `40c623ed`)报 `Failed to fetch Inter from Google Fonts`,`railway-web.Dockerfile:26` 退出 1。
|
||||
- 修复:把 latin 可变 Inter(OFL)放进 `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:15–05: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**。文档 v2:charts=9(D1/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` 合同上限未放宽。
|
||||
|
||||
@@ -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-527~530) |
|
||||
| `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-531~532) |
|
||||
| `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`(BUG-536~539) |
|
||||
| `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-540~541) |
|
||||
| — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-544~545) |
|
||||
| `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-505~509) |
|
||||
|
||||
### 聊天主链路与首页
|
||||
@@ -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:00(UTC+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` L38–45、`frontend/src/app/api/rectification/agent/route.ts` L143–153):
|
||||
|
||||
```
|
||||
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. 事故实证(staging,2026-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`(L901–997)、`candidate_contrast.py::candidate_split_hash`(hash 把 `year/month` 掺进去,所以同分组不同年 ≠ 同一 hash) |
|
||||
| C | 第二张卡的 `question_id` / `probe_id` / 评分都指向**毕业**探针,但卡面题干与日期标签是**入学**探针的 | `choice-card.ts::pickProbe`(L233–251):`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
@@ -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`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
|
||||
@@ -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.
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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。采集题没有 A–D 卡,所以 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:00–05: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:47–04: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"/);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user