From c5fbee56a6a84ee5660faece5ec18ab489060acd Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 14 Sep 2026 23:20:15 +0800 Subject: [PATCH] fix(rectification): label declared birth time as record or estimate (BUG-690) Hospital records, family estimates and period-only windows now keep distinct copy on the board and range card. Scoring still uses the reported clock as the search-window centre. Hospital minutes outside the current range are stated with the offset and no preference. --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 ++ ...fication-birth-time-provenance-20260914.md | 31 +++ docs/tasks/README.md | 2 +- ...fication-birth-time-provenance-20260914.md | 10 + frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 1 + frontend/src/app/globals.css | 6 + .../components/rectification-agentic-chat.tsx | 7 + .../src/components/rectification-board.tsx | 11 +- .../rectification-range-delivery.tsx | 3 + .../birth-time-provenance.ts | 113 +++++++++++ .../core/rectification-decision.ts | 5 + .../lib/rectification-agentic/user-copy.ts | 22 +++ .../v9/case-dossier-response.ts | 6 + .../v9/decision-from-dossier.ts | 54 +++--- .../v9/divergence-panel.ts | 21 ++ frontend/src/lib/rectification-board-model.ts | 7 +- .../src/lib/rectification-surface-state.ts | 18 +- .../tests/agent-voice-copy-contract.test.ts | 13 ++ ...ion-birth-time-provenance-20260914.test.ts | 183 ++++++++++++++++++ .../tests/rectification-board-model.test.ts | 2 + .../rectification-surface-contract.test.ts | 4 +- .../tests/rectification-surface-state.test.ts | 9 +- .../references/candidate-comparison.md | 16 +- 25 files changed, 531 insertions(+), 35 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-birth-time-provenance-20260914.md create mode 100644 docs/testing/rectification-birth-time-provenance-20260914.md create mode 100644 frontend/src/lib/rectification-agentic/birth-time-provenance.ts create mode 100644 frontend/tests/rectification-birth-time-provenance-20260914.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5968d381..cd3b80f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-14 — 报上来的时间会标明是记录还是大概记得 + +生时校正里,医院记录称作「出生记录时间」,家人记得称作「你填的大概时间」,只知道时段称作「你给的时间段」,不再把后两类叫成「你的出生时间」。和目前范围比较时只说相差几分钟;有医院记录却落在范围外时,两者并列,不替你选边。Skill 版本不变。 + ## 2026-09-14 — 并列时请再补一件带年月的经历,不再说「问完了」 生时校正里,固定采集线问完、几个候选仍然分不开时,范围卡照出,但会请你再说一件记得确切哪一天的事,不限领域(登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天)。记不得哪一天的,有年月也行。补完会接着算。不再写「能问的都问完了」。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cd69ca6c..053d059d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10741,3 +10741,19 @@ - 相关记录:BUG-687、BUG-646、BUG-651、BUG-653、BUG-688、BUG-590 - 复发自:BUG-687(空池收口写成终局句) - 修复版本:待发布 + +## BUG-690 | 家人推算的出生时间与医院记录等同对待,输出直接称「你的出生时间」 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`birth_time_source` 投影、交付卡、宫位板、采用旁白 +- 用户现象:录入时已选「医院记录 / 家人记得 / 只知道时段」,校正链里决策和文案不看这个字段。家人事后推算的时间和出生证上的分钟被同样称作出生时间。 +- 触发条件:`approximate` 或 `period_only` Case 进入交付或采用;或 `hospital_record` 的分钟落在目前范围外。 +- 根因:`birth_time_source` 只在放宽窗口时改写过一次,公开投影和用户文案没有来源标签。 +- 修复:GET / `decideFromDossier` 投影带 `birth_time_source`(缺失按 `approximate`)。文案分档:医院记录称「出生记录时间」并可并列与目前范围的差值(不站队);家人记得称「你填的大概时间」;只知道时段称「你给的时间段」。不改打分与搜索窗中心。医院记录与范围冲突时如何取舍挂给产品负责人。 +- 验证:`frontend/tests/rectification-birth-time-provenance-20260914.test.ts`、`agent-voice-copy-contract.test.ts`。 +- 防复发:`birth_time_source` 不是录入时的一次性字段,任何指代出生时间的输出都必须按它分档;推算值不得表述为已证实的出生时间。 +- 相关记录:BUG-587、BUG-621、BUG-689 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-birth-time-provenance-20260914.md b/docs/tasks/PROGRESS-rectification-birth-time-provenance-20260914.md new file mode 100644 index 00000000..0ba347f2 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-birth-time-provenance-20260914.md @@ -0,0 +1,31 @@ +# 进度 · 出生时间来源标签(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-birth-time-provenance-20260914`(基于 `origin/staging` @ `a266b6b7`) +- 任务单:`docs/tasks/TASK-rectification-birth-time-provenance-20260914.md` +- BUG-690 resolved +- 未改打分、搜索窗中心、确认门、数据库字段、Skill 版本 + +## 完成 + +- T1:`decideFromDossier` / GET `case` / `interview` 投影带 `birth_time_source`。`hospital_record` / `period_only` 原样;其余(含 `family_exact`、缺省)落到 `approximate`。 +- T2:宫位板空态与交付卡按来源分档。家人记得 / 只知道时段不得出现「你的出生时间」。医院记录写「出生记录时间」;落在目前范围内如实说在范围内,落在外面只并列相差分钟,不站队。 +- T3:只改 live `references/candidate-comparison.md` 增补「出生时间来源标签」;不 bump 10.0.26(避免 SHA / 历史 Case 打不开)。Agent 绑定包仍是冻结的 10.0.26,下一次 Skill 发版再吃进包。 +- 未走让步于 T1/T2。T3 按让步 2 不 bump。 + +## 挂给产品负责人(D4) + +医院记录的分钟落在目前范围外时,本单只并列陈述两者与差值。尚未决定:以记录为准、以证据为准,还是长期并列。请产品拍板后再单独立项。 + +## 验收数字 + +- 新测 `rectification-birth-time-provenance-20260914.test.ts` 3/3 +- 定向:`agent-voice-copy-contract`、`rectification-surface-state`、`rectification-surface-contract`、`rectification-board-model` 全绿 +- `tsc --noEmit`:0 错 +- `npx eslint` 改动文件:0 error +- `next build`:本工作树 `frontend/node_modules` 是 junction,未跑 + +## 环境缺口 + +- 无 staging 登录态、无 Chrome:真机清单见 `docs/testing/rectification-birth-time-provenance-20260914.md` diff --git a/docs/tasks/README.md b/docs/tasks/README.md index bf3c33df..6de2ffa0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -186,7 +186,7 @@ | `TASK-rectification-tiebreak-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待验收 | `codex/rectification-tiebreak-hold-exit-20260914` | -| `TASK-rectification-birth-time-provenance-20260914.md` | `PROGRESS-rectification-birth-time-provenance-20260914.md` | 与上游流程对照发现的缺口(上游 R2):录入时已问「医院记录 / 家人记得大概 / 只知道时段」并落库 `birth_time_source`,但整条校正链只在放宽窗口时用过一次,决策与文案一律不看它——家人推算的时间和出生证在系统里等同对待,输出还直接称「你的出生时间」。本单把来源标签接进投影并分档措辞(BUG-690) | 待执行 | `codex/rectification-birth-time-provenance-20260914` | +| `TASK-rectification-birth-time-provenance-20260914.md` | `PROGRESS-rectification-birth-time-provenance-20260914.md` | 与上游流程对照发现的缺口(上游 R2):录入时已问「医院记录 / 家人记得大概 / 只知道时段」并落库 `birth_time_source`,但整条校正链只在放宽窗口时用过一次,决策与文案一律不看它——家人推算的时间和出生证在系统里等同对待,输出还直接称「你的出生时间」。本单把来源标签接进投影并分档措辞(BUG-690) | 待验收 | `codex/rectification-birth-time-provenance-20260914` | | `TASK-rectification-precision-adaptive-boundary-research-20260914.md` | `PROGRESS-rectification-precision-gate-research-20260914.md` | **研究单**:出题闸门 `MIN_BOUNDARY_DAYS=45`(刷新 30)不看证据精度,而出生时间每差 1 分钟大运边界只平移约 1.1 天——20 分钟窗内的候选边界全挤在三周内被整批丢掉,这就是「六题后出不来题」的算术原因。量闸门按年/月/日分档(G0–G4)的收益,并必测答错容忍度(±3/±7/±14 天偏移下真值是否被挤出) | 待执行 | `codex/rectification-precision-gate-research-20260914` | diff --git a/docs/testing/rectification-birth-time-provenance-20260914.md b/docs/testing/rectification-birth-time-provenance-20260914.md new file mode 100644 index 00000000..a9c3c86a --- /dev/null +++ b/docs/testing/rectification-birth-time-provenance-20260914.md @@ -0,0 +1,10 @@ +# 出生时间来源标签:真机清单(2026-09-14) + +分支 `codex/rectification-birth-time-provenance-20260914`。本环境无 staging 登录态。 + +## BUG-690 + +- [ ] 医院记录:宫位板空态写「出生记录时间 HH:MM」,不写「你的出生时间」。范围卡若该分钟在目前范围内,写落在范围内;若在范围外,写出生记录时间、目前范围不含这一分钟、相差 N 分钟。不出现「以记录为准」或「以证据为准」。 +- [ ] 家人记得大概:宫位板写「你填的大概时间 HH:MM」。范围卡写「与你填的大概时间相差 N 分钟」。全文没有「你的出生时间」。 +- [ ] 只知道时段:不逼补一个钟点。若没有具体分钟,宫位板不发明一个钟点。不出现「你的出生时间」。 +- [ ] 采用入口仍是「更像这个」;没有多出来的来源开关。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 6464b26c..817ac88b 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -241,7 +241,7 @@ The birth-time rectification session is the consultation transcript plus a house | stopped | the row settles with what streamed; a grey caption “已停止,已生成的内容保留;本次不会扣点。” under the body, never `role="alert"` | enabled | - **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a repair button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves. -- **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one. +- **Board:** before any candidate exists the header clock shows the declared birth minute and the body names it by source — “出生记录时间 HH:MM”, “你填的大概时间 HH:MM”, or “你给的时间段” — then “回答几个问题后,这里会显示宫位随时间的变化.”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one. The range card may add a caption comparing the declared clock to the current range (“与你填的大概时间相差 N 分钟”, or a hospital-record offset with no preference). - **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the repair and start actions are real 44px buttons. ### Birth time intake diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 713632f5..0a6a0e7c 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -47,6 +47,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 | | 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 | | 能问的都问完了 | 这两分钟按现有信息分不开。你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 | +| 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 | | 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集只有输入框,没有「没有」「记不清」芯片,也不挂在输入框上方。范围小字是状态句,不可点。 | 想跳过可以打字「没有」或「记不清」。 | | 只报一件后立刻按家人/学业/财务轮着问,题干带「2020 年前后」 | 记下后问「还有吗?比如……」(只列还没提过的具体事)。答「没有了」后才从用户说过的年份追问,例如「2020 年毕业后第一份工作大概哪年开始?」 | 先等用户说完;年份只来自用户,不用生日推。 | | 点选后旁白写「04:31–04:39 这段领先,05:00–05:07 这段落后」 | 已记录,范围收到 04:31–04:39。 | 旁白只说范围变没变。领先/落后留在卡内「答了会怎样」,不进气泡。 | diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2e0b64f9..89cb3cf0 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3396,6 +3396,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-body-md); line-height: 1.65; } +.rectification-range-delivery__provenance { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-caption); + line-height: 1.5; +} .rectification-range-delivery__boundary { margin: 0; color: var(--color-ink-secondary); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 76cc688f..ebadc9f1 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -73,6 +73,7 @@ import { persistedQuestionSurface, interviewStopReasonFromSnapshot, interviewSessionOutcomeFromSnapshot, + birthTimeSourceFromSnapshot, type RectificationCaseSnapshotPayload, } from "@/lib/rectification-surface-state"; import { RectificationTimeline } from "@/components/rectification-timeline"; @@ -394,6 +395,7 @@ type CaseSnapshotState = Readonly<{ stage: RectificationTimelineStage | null; interviewStopReason: string | null; interviewSessionOutcome: string | null; + birthTimeSource: ReturnType; }>; function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null { @@ -419,6 +421,7 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca stage: caseStageFromSnapshot(payload.case?.stage), interviewStopReason: interviewStopReasonFromSnapshot(payload), interviewSessionOutcome: interviewSessionOutcomeFromSnapshot(payload), + birthTimeSource: birthTimeSourceFromSnapshot(payload), }; } @@ -464,6 +467,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [nextUserActionId, setNextUserActionId] = useState(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null); const [interviewStopReason, setInterviewStopReason] = useState(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null); const [interviewSessionOutcome, setInterviewSessionOutcome] = useState(() => caseSnapshotState(initialSnapshot)?.interviewSessionOutcome ?? null); + const [birthTimeSource, setBirthTimeSource] = useState(() => caseSnapshotState(initialSnapshot)?.birthTimeSource ?? birthTimeSourceFromSnapshot(initialSnapshot)); const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0); @@ -637,6 +641,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setNextUserActionId(nextActionId || null); setInterviewStopReason(interviewStopReasonFromSnapshot(snapshot)); setInterviewSessionOutcome(interviewSessionOutcomeFromSnapshot(snapshot)); + setBirthTimeSource(birthTimeSourceFromSnapshot(snapshot)); setCaseSnapshotLoaded(true); if (nextQuestion || nextChoice || acceptedTime || confirmedTime) { setQuestionRepairAttempts(0); @@ -1683,6 +1688,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { function RectificationBoardBody({ result, declaredTime, + birthTimeSource, savedStatus, diff, compact, @@ -47,6 +48,7 @@ function RectificationBoardBody({ }: Readonly<{ result: RectificationCandidateResult | null; declaredTime: string | null; + birthTimeSource?: string | null; savedStatus: "accepted" | "confirmed" | null; diff: RectificationBoardDiff; compact: boolean; @@ -55,7 +57,7 @@ function RectificationBoardBody({ }>) { const table = result ? workingRectificationHouseTable(result) : null; const workingTime = workingRectificationTime(result); - const emptyCopy = rectificationBoardEmptyCopy(declaredTime); + const emptyCopy = rectificationBoardEmptyCopy(declaredTime, birthTimeSource); const grouped = result ? groupWindowTransitions(result.windowTransitions) : []; const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0); const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0); @@ -220,6 +222,7 @@ function RectificationBoardBody({ export function RectificationBoard({ result, declaredTime, + birthTimeSource, savedStatus, diff, compact, @@ -230,6 +233,7 @@ export function RectificationBoard({ }: Readonly<{ result: RectificationCandidateResult | null; declaredTime: string | null; + birthTimeSource?: string | null; savedStatus: "accepted" | "confirmed" | null; diff: RectificationBoardDiff; compact: boolean; @@ -265,6 +269,7 @@ export function RectificationBoard({ void; }>) { - const peek = rectificationBoardPeekCopy(result, declaredTime); + const peek = rectificationBoardPeekCopy(result, declaredTime, birthTimeSource); return (