diff --git a/CHANGELOG.md b/CHANGELOG.md index 417072f9..6943f05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-14 — 口述追问挂回助手消息;同屏只写一个范围;引擎代表时间不进模型上下文 + +生时校正里,定向补事答「有过这件事」之后的「大概哪年几月?」会跟选择题一样出现在助手消息里,带头像,不再是时间轴下面一行裸题。采集旁白「现在还剩 X–Y」与时间轴「目前范围」用同一段可信区间。已被淘汰的引擎代表分钟不会再把宫位表送给模型。Skill 版本不变。 + + ## 2026-09-14 — 探针耗尽后不再把判别题念进正文;有卡不再画裸题 生时校正里,带年月题问完、探针盖不上号时,不会再把同一句判别题写进正文让人打字。会改出定向补事点选卡。若快照里已经有选择题卡,界面不再只画一行题干。代表时间如果落在被淘汰候选或范围外,会记一条告警,排序逻辑本轮不动。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cfe26370..20f1d860 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10519,17 +10519,17 @@ ## BUG-676 | 代表时间取到被淘汰候选 -- 状态:investigating +- 状态:resolved - 首次发现:2026-09-14 - 最近更新:2026-09-14 -- 影响面:`evaluateCandidateSeparation`、`rankActive`、decision receipt `representative_time` / `last_inference_round` -- 用户现象:同一轮 receipt 里 `representative_time` 落在 `eliminated_ids` 中(例如 05:14),界面范围是 04:48–05:07,也不含该分钟。 -- 触发条件:本轮有淘汰名单,对外代表时间与 `winner_id` / 范围不一致。 -- 根因:未知。已确认 `evaluateCandidateSeparation` 按 score 取 `ranked[0]`,`evaluateConvergence` 先滤 `status === eliminated` 再按后验排序。尚未拿到同一份 `inference_state.candidates`(id / time / status / probability / posterior_score)与 `credible_range`,不能断定是 status 没写回、后验没重算,还是两套分数不是同一份。 -- 修复:本轮只加不变量告警 `rectification_representative_time_inconsistent`(代表时间在 eliminated 内或落在 credible_range 外)。不改排序。 -- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts` 两条告警路径。 -- 防复发:代表时间必须不在本轮 `eliminated_ids` 内,且落在 `credible_range` 内。根因确认后另立修复单,不得为了「数字看起来对」顺手改排序。 -- 相关记录:BUG-442 +- 影响面:`evaluateCandidateSeparation`、`rankActive`、decision receipt `representative_time` / `last_inference_round`、`slimDecisionReceipt` +- 用户现象:同一轮 receipt 里引擎 `representative_time` 落在 `eliminated_ids` 中(例如 05:14),界面范围是 04:48–05:07,也不含该分钟。 +- 触发条件:本轮有淘汰名单,引擎代表时间与前端推理第一名 / 范围不一致。 +- 根因:引擎与前端是两套代表时间,未对账。Python 引擎按自身事件拟合排整张网格,不吃前端累积问答,把 05:14 写进结果行;`overlayPublicDecision` 在 `fromInference` 时用推理值覆盖,**界面从未显示过 05:14**。真实副作用是 `case-receipt-projection` 把 `latestResult.representativeTime`(引擎值)算进活跃时间集合,已被淘汰分钟的 `house_tables_by_time` 仍送给模型。 +- 修复:活跃时间集合去掉引擎 `representativeTime`,只保留推理活跃候选、`inference.representative_time`、用户已采用的 `selectedTime`。不改排序。上一轮的不变量告警 `rectification_representative_time_inconsistent` 保留。 +- 验证:`frontend/tests/rectification-spoken-orphan-20260914.test.ts`(淘汰分钟不进 `house_tables_by_time`;`selectedTime` 仍保留);告警路径仍由 `rectification-unstampable-probe-20260914.test.ts` 覆盖。 +- 防复发:引擎代表时间不得进入模型上下文的活跃宫位表,也不得显示给用户,不得反过来覆盖前端推理值。 +- 相关记录:BUG-442、BUG-678 - 复发自:无 - 修复版本:待发布 @@ -10548,3 +10548,19 @@ - 相关记录:BUG-149、BUG-364 - 复发自:无 - 修复版本:待发布 + +## BUG-678 | 口述题没有 askedTurnId 时仍是无头像裸行 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`attachQuestionsToTurns`、`focusSpokenPrompt`、`rectificationQuestionGapState` / `persisted_question` +- 用户现象:定向补事答「有过这件事」之后,助手气泡只有「记下了。」,时间轴「目前范围」下面一行没有头像的裸题「大概哪年几月?」。 +- 触发条件:年份追问焦点 `collect:targeted::year`、`kind = collect_spoken`、`askedTurnId = null`、`status = active`;上一轮挂回规则只覆盖选择题。 +- 根因:BUG-675 把 `attachQuestionsToTurns` 的 hanging 限定成 `turnQuestionKind(focus) === "choice"`。口述题没有卡可画,但同样需要头像与消息位置;短题干「大概哪年几月?」只有 7 字,`focusSpokenPrompt` 的 8 字下限会让挂回后的 `turnQuestionFromFocus` 仍返回空。 +- 修复:无 `askedTurnId` 的活动焦点一律可挂到最后一条尚无题的助手消息;采集 schema 允许短于 8 字的题干。`persisted_question` 纯文本块只留给一条助手消息都没有的兜底。年份追问仍是口述,不做点选卡。 +- 验证:`frontend/tests/rectification-spoken-orphan-20260914.test.ts`;`rectification-collect-prompt.test.ts` 补口述挂回。 +- 防复发:挂回规则不得再按 `choice` 排除口述题。不得把年份追问改成点选卡来绕过渲染。不得新增第二套问题组件。 +- 相关记录:BUG-675、BUG-673、BUG-671 +- 复发自:BUG-675(同一块兜底只修了选择题) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-spoken-orphan-20260914.md b/docs/tasks/PROGRESS-rectification-spoken-orphan-20260914.md new file mode 100644 index 00000000..dee29d00 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-spoken-orphan-20260914.md @@ -0,0 +1,23 @@ +# 进度 · 口述题孤儿裸行与引擎代表时间(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-spoken-orphan-20260914`(基于 `origin/staging` @ `a5acd535`) +- 任务单:`docs/tasks/TASK-rectification-spoken-orphan-and-engine-representative-20260914.md` +- BUG-678 resolved;BUG-676 从 investigating 收敛为 resolved +- 任务 1/2/3 均做完,没有走让步 + +## 完成 + +- T1:`attachQuestionsToTurns` 对无 `askedTurnId` 的活动焦点一律挂回最后一条尚无题的助手消息;`focusSpokenPrompt` 允许采集 schema 短于 8 字(「大概哪年几月?」)。`persisted_question` 只留给没有助手消息可挂的兜底。 +- T2:`remainingCandidatesLine` 优先用 `credible_range`,缺失才退回活跃候选首尾。`targetedCollectPool` / `rangeNarrowHint` / `targetedCollectFollowup` / catalog 同步传入。`remainingSplitTimes` 的排序/去重用途未改。 +- T3:`slimDecisionReceipt` 活跃时间集合去掉 `latestResult.representativeTime`;保留推理活跃候选、`inference.representative_time`、用户已采用的 `selectedTime`。不改排序。 +- T4:BUG-678 新增;BUG-676 根因更正为两套代表时间未对账,并写明界面从未显示 05:14。CHANGELOG、真机清单、DESIGN 范围口径。 + +## 验收数字 + +- `./node_modules/.bin/tsc --noEmit`:0 错 +- `npm run lint`:0 error(120 warning,与基线同类,无新增 error) +- 相关单测 46/46:`rectification-spoken-orphan-20260914.test.ts` 3/3,加上 collect-prompt / collection-question-pool / case-receipt-projection / unstampable-probe / surface-state +- `npm test`:2989 tests,2822 pass / 153 fail / 14 skip。比上一轮 Windows 基线(2985 / 2818 / 153 / 14)多 4 条全绿用例;失败清单仍是 Caddy/CRLF、Docker、bash、workflow YAML、Skill SHA、page.tsx 源码正则,没有新的校正产品失败 +- `next build`:本工作树 `frontend/node_modules` 是 junction,Turbopack 拒绝软链;`--webpack` 编译与 tsc 通过,收集 page data 时因本机 Skill 10.0.26 SHA 与 registry 不一致失败(环境问题,未改 skill 包)。`/` 是否仍 `○ Static` 需在有真实 `node_modules` 且 skill 哈希匹配的树上复核 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 935c455f..095967e8 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -184,7 +184,7 @@ | `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` | -| `TASK-rectification-spoken-orphan-and-engine-representative-20260914.md` | `PROGRESS-rectification-spoken-orphan-20260914.md` | **P0**:口述题(定向补事年份追问)没有 `askedTurnId` 时仍是无头像裸行——BUG-675 的挂回规则只覆盖选择题(BUG-678);同屏两个范围口径(旁白用活跃候选首尾、时间轴用 credible_range);引擎 `representative_time` 把已淘汰分钟的宫位表带进模型上下文(BUG-676 收敛为 resolved) | 待执行 | `codex/rectification-spoken-orphan-20260914` | +| `TASK-rectification-spoken-orphan-and-engine-representative-20260914.md` | `PROGRESS-rectification-spoken-orphan-20260914.md` | **P0**:口述题(定向补事年份追问)没有 `askedTurnId` 时仍是无头像裸行——BUG-675 的挂回规则只覆盖选择题(BUG-678);同屏两个范围口径(旁白用活跃候选首尾、时间轴用 credible_range);引擎 `representative_time` 把已淘汰分钟的宫位表带进模型上下文(BUG-676 收敛为 resolved) | 待验收 | `codex/rectification-spoken-orphan-20260914` | | `TASK-rectification-unstampable-probe-and-naked-card-20260914.md` | `PROGRESS-rectification-unstampable-probe-20260914.md` | **P0**:探针池空后判别题盖不上 probe → 焦点写不进 → 题干被念进正文、同一题反复问且答了不算数(BUG-674,推翻前一单 D3);快照已有 `choice_card` 但 `persisted_question` 仍只画一行裸题(BUG-675);同一轮 receipt 里 `representative_time` 取到 `eliminated_ids` 里的候选(BUG-676,investigating) | 待验收 | `codex/rectification-unstampable-probe-20260914` | diff --git a/docs/testing/rectification-spoken-orphan-20260914.md b/docs/testing/rectification-spoken-orphan-20260914.md new file mode 100644 index 00000000..3be5d561 --- /dev/null +++ b/docs/testing/rectification-spoken-orphan-20260914.md @@ -0,0 +1,21 @@ +# 口述题挂回、同屏范围、引擎代表时间:真机清单(2026-09-14) + +分支 `codex/rectification-spoken-orphan-20260914`。本环境无 staging 登录态。 + +## BUG-678 + +进入当时那个校正会话,或再走一遍「定向补事答 A(有过这件事)」。 + +- [ ] 答 A 之后出现「大概哪年几月?」,带头像,在助手消息里,不在时间轴下面一行裸题。 +- [ ] 输入框提示指向上面的问题,而不是时间轴下方再出一块无头像题干。 +- [ ] 年份追问仍是口述,没有被做成 A/B/C/D 点选卡。 + +## 同屏范围口径 + +- [ ] 助手旁白「现在还剩 HH:MM–HH:MM 里 N 个候选」与时间轴「目前范围 HH:MM–HH:MM」起止分钟完全一致。 +- [ ] 候选个数仍是还活着的分钟数,不是区间宽度。 + +## BUG-676 + +- [ ] 界面代表分钟仍是前端推理第一名,不会突然变成已被淘汰的分钟。 +- [ ] 打开当时那轮送给模型的 receipt(脱敏):`house_tables_by_time` 不含已淘汰的引擎代表分钟。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 629f02f8..7a1a8b8e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -226,8 +226,8 @@ The birth-time rectification session is the consultation transcript plus a house |---|---|---| | `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | enabled (typing queues), stop visible | | `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled | -| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card | -| `question-gap`, persisted question | snapshot already has the next prompt, including a completed turn that claimed to record evidence but left the same focus unanswered; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” | +| `question-live` | the asking message carries the embedded card or the spoken stem. An active spoken or choice focus without `asked_turn_id` hangs on the last assistant message, same avatar column | enabled, placeholder points at the card | +| `question-gap`, persisted question | only when no assistant message can carry the prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” | | `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | | `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled | | `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap | enabled, placeholder “再说一件带年月的事” | @@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable. - **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation. - **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring. -- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title “目前范围 HH:MM–HH:MM(对照了 N 件经历)”, a caption “还能再收窄:如果记得 …” under the title, up to three compare columns (time, relative likelihood, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”), and the representative-minute boundary. An eight-method report sits in a closed `
` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time. +- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title “目前范围 HH:MM–HH:MM(对照了 N 件经历)”, a caption “还能再收窄:如果记得 …” under the title, up to three compare columns (time, relative likelihood, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”), and the representative-minute boundary. Collect copy “现在还剩 HH:MM–HH:MM 里 N 个候选” uses the same `credible_range` as the timeline “目前范围”, not the first/last active candidate minute. An eight-method report sits in a closed `
` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time. - **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence. - **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Spoken collect does not render skip chips; typing 「没有」 still declines the domain and 「记不清」 still skips it. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The method sentence (`vargaSentence`) lives in the expanded activity timeline, not in the spoken bubble. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`. - **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey. diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 77d22c56..af543307 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -332,6 +332,8 @@ function adoptHostNarration(input: { input.dossier.evidence, input.dossier.conversationSummary.declinedSkippedTopics, catalog.remainingSplitTimes, + undefined, + catalog.remainingCredibleRange, ); if (!hint || delivered.includes(hint)) return delivered; return `${delivered} ${hint}`.replace(/\s+/g, " ").trim(); @@ -1781,6 +1783,7 @@ async function persistExhaustionCollect(input: { dossier.conversationSummary.declinedSkippedTopics, catalog.remainingSplitTimes, catalog.remainingCandidateCount, + catalog.remainingCredibleRange, ); const trainingGate = trainingScoreableGate(dossier.evidence); const ceiling = engineCapabilityCeilingFromReceipt(receipt); @@ -1889,6 +1892,7 @@ async function persistExhaustionCollect(input: { dossier.conversationSummary.declinedSkippedTopics, catalog.remainingSplitTimes, catalog.remainingCandidateCount, + catalog.remainingCredibleRange, ); targetedSkipCount += 1; continue; @@ -1972,6 +1976,7 @@ async function persistExhaustionCollect(input: { dossier.conversationSummary.declinedSkippedTopics, catalog.remainingSplitTimes, catalog.remainingCandidateCount, + catalog.remainingCredibleRange, ); const range = nonConvergingRangeNarration({ credibleRange: decision.credibleRange ?? input.decision.credibleRange, diff --git a/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts b/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts index 84922214..c09fb7f3 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-receipt-projection.ts @@ -13,7 +13,7 @@ export function slimDecisionReceipt( : dossier.latestResult?.candidates ?? []) { activeTimes.add(candidate.time); } - for (const value of [inference?.representative_time, dossier.latestResult?.representativeTime, dossier.latestResult?.selectedTime]) { + for (const value of [inference?.representative_time, dossier.latestResult?.selectedTime]) { if (typeof value === "string" && value.trim()) activeTimes.add(value.slice(0, 5)); } const focusSchema = dossier.conversationSummary.activeFocus?.expectedAnswerSchema ?? {}; diff --git a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts index 313f4c18..514fd0b3 100644 --- a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts +++ b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts @@ -751,13 +751,23 @@ function remainingTargetedDomains( return domains; } +function clockRangePair( + value: readonly [string, string] | null | undefined, +): readonly [string, string] | null { + const start = clockValue(value?.[0]); + const end = clockValue(value?.[1]); + return start && end ? [start, end] : null; +} + export function remainingCandidatesLine( splitTimes: readonly [string, string] | null | undefined, candidateCount: number, examples: readonly string[], + credibleRange?: readonly [string, string] | null, ): string | null { - if (!splitTimes?.[0] || !splitTimes[1] || candidateCount < 1 || examples.length === 0) return null; - return `现在还剩 ${splitTimes[0]}–${splitTimes[1]} 里 ${candidateCount} 个候选,能把它们分开的是这几条线:${examples.join("、")}`; + const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes); + if (!range || candidateCount < 1 || examples.length === 0) return null; + return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选,能把它们分开的是这几条线:${examples.join("、")}`; } export function targetedCollectPool( @@ -766,6 +776,7 @@ export function targetedCollectPool( declinedTopics: readonly CollectionTopic[] = [], splitTimes?: readonly [string, string] | null, candidateCount?: number, + credibleRange?: readonly [string, string] | null, ): CollectionPoolItem[] { if (pendingTargetedYearDomain(declinedTopics, evidence)) return []; const declined = collectDeclinedKinds(declinedTopics); @@ -773,7 +784,7 @@ export function targetedCollectPool( if (domains.length === 0) return []; const count = candidateCount ?? 0; const examples = domains.map((domain) => TARGETED_EXAMPLES[domain][0]); - const remainingLine = remainingCandidatesLine(splitTimes, count, examples); + const remainingLine = remainingCandidatesLine(splitTimes, count, examples, credibleRange); return domains.map((domain) => { const existencePrompt = TARGETED_EXISTENCE_PROMPT[domain]; return { @@ -821,15 +832,24 @@ export function rangeNarrowHint( declinedTopics: readonly CollectionTopic[] = [], splitTimes?: readonly [string, string] | null, candidateCount?: number, + credibleRange?: readonly [string, string] | null, ): string { - const open = targetedCollectPool(remainingLayers, evidence, [], splitTimes, candidateCount); + const open = targetedCollectPool(remainingLayers, evidence, [], splitTimes, candidateCount, credibleRange); const remaining = remainingCandidatesLine( splitTimes, candidateCount ?? 0, open.map((item) => TARGETED_EXAMPLES[item.domain as CollectKind]?.[0] ?? item.examples?.[0] ?? "").filter(Boolean), + credibleRange, ); const hint = targetedCollectHintFromPool(open) - ?? targetedCollectHint(targetedCollectPool(remainingLayers, evidence, declinedTopics, splitTimes, candidateCount)[0]) + ?? targetedCollectHint(targetedCollectPool( + remainingLayers, + evidence, + declinedTopics, + splitTimes, + candidateCount, + credibleRange, + )[0]) ?? `还能再收窄:${moreCollectHint(evidence, declinedTopics)}`; if (remaining && !hint.startsWith(remaining)) { return `${remaining}。${hint}`; diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 51acbe32..4c4f4cd0 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -393,6 +393,7 @@ export function rectificationFollowupCatalog( remainingLayers, remainingSplitTimes: remainingSplitTimes(activeTimes.length ? activeTimes : topCandidateTimes), remainingCandidateCount: remainingCandidateCount(activeTimes.length ? activeTimes : topCandidateTimes), + remainingCredibleRange: inference?.credible_range ?? null, }; } @@ -779,6 +780,7 @@ export function decideFromDossier( remainingLayers: catalog.remainingLayers, remainingSplitTimes: catalog.remainingSplitTimes, remainingCandidateCount: catalog.remainingCandidateCount, + remainingCredibleRange: catalog.remainingCredibleRange, ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, @@ -898,6 +900,7 @@ export function decideAfterInferenceChange(input: { remainingLayers: catalog.remainingLayers, remainingSplitTimes: catalog.remainingSplitTimes, remainingCandidateCount: catalog.remainingCandidateCount, + remainingCredibleRange: catalog.remainingCredibleRange, ...followupCaseArgs({ stage: input.dossier.case.stage, blockScan: input.dossier.case.blockScan, diff --git a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts index 177780ef..0f6b2442 100644 --- a/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts +++ b/frontend/src/lib/rectification-agentic/v9/divergence-panel.ts @@ -416,6 +416,7 @@ export function buildRangeDelivery(input: { input.declinedTopics ?? [], remainingSplitTimes(activeTimes), remainingCandidateCount(activeTimes), + inference?.credible_range, ), tie_break_available: input.tieBreakAvailable === true, }; diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index f29027cf..dadd1bbe 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -1448,6 +1448,7 @@ export function targetedCollectFollowup( declinedTopics: readonly Readonly>[] = [], splitTimes?: readonly [string, string] | null, candidateCount?: number, + credibleRange?: readonly [string, string] | null, ): MethodFollowup | null { const pendingYear = pendingTargetedYearDomain(declinedTopics, evidence); if (pendingYear) { @@ -1459,9 +1460,10 @@ export function targetedCollectFollowup( declinedTopics, splitTimes, candidateCount, + credibleRange, )[0]; if (!top) return null; - return targetedCollectExistenceFollowup(top, splitTimes, candidateCount); + return targetedCollectExistenceFollowup(top, splitTimes, candidateCount, credibleRange); } export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup { @@ -1490,6 +1492,7 @@ function targetedCollectExistenceFollowup( item: CollectionPoolItem, splitTimes?: readonly [string, string] | null, candidateCount?: number, + credibleRange?: readonly [string, string] | null, ): MethodFollowup { const base = followupFromPoolItem(item); const frame = buildTargetedCollectExistenceFrame({ @@ -1502,6 +1505,7 @@ function targetedCollectExistenceFollowup( splitTimes, candidateCount ?? remainingCandidateCount(), item.examples?.length ? [item.examples[0]!] : [], + credibleRange, ); return { ...base, @@ -2098,6 +2102,7 @@ export function buildMethodFollowupPlan(input: { remainingLayers?: readonly string[]; remainingSplitTimes?: readonly [string, string] | null; remainingCandidateCount?: number; + remainingCredibleRange?: readonly [string, string] | null; tieBreakRequested?: boolean; }): MethodFollowupPlan { const makeFollowup = ( @@ -2762,6 +2767,7 @@ export function buildMethodFollowupPlan(input: { ], input.remainingSplitTimes, input.remainingCandidateCount, + input.remainingCredibleRange, )) ) { // BUG-651: no yearless personality as the next discriminator. @@ -2963,6 +2969,7 @@ export function buildMethodFollowupPlan(input: { ], input.remainingSplitTimes, input.remainingCandidateCount, + input.remainingCredibleRange, ); if (targeted) next = targeted; else if (pendingHoldout) next = makeFollowup(pendingHoldout, false); diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index 67bcf43d..401889fb 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -25,7 +25,8 @@ const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop", "skip_probe"]); export function focusSpokenPrompt(schema: Readonly> | null | undefined): string | null { if (!schema) return null; const top = typeof schema.prompt === "string" ? schema.prompt.trim() : ""; - if (top.length >= 8 && top.length <= 120) return top; + // Collect schemas keep short stems such as "大概哪年几月?" (7 chars). + if (top && top.length <= 120 && (schema.collect === true || top.length >= 8)) return top; const copy = parseAgentChoiceCopy(schema); const fromChoice = copy?.prompt?.trim() ?? ""; return fromChoice || null; @@ -107,9 +108,7 @@ export function attachQuestionsToTurns { @@ -89,9 +93,9 @@ test("runtime no longer composes the stem into assistant_message", () => { const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/); assert.match(attach, /stripQuestionSentences/); - // 原值: if (!focus.askedTurnId) continue - // 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue - // 原因: BUG-675 焦点没有 asked_turn_id 时卡片仍要出现在消息上 + // 原值: 无 askedTurnId 的活动选择题才挂回 + // 新值: 无 askedTurnId 的活动焦点(含口述题)都挂回 + // 原因: BUG-678 口述年份追问也要出现在助手消息里 assert.match(attach, /if \(!focus\.askedTurnId\) \{/); }); diff --git a/frontend/tests/rectification-collection-question-pool.test.ts b/frontend/tests/rectification-collection-question-pool.test.ts index f92834db..11d951fb 100644 --- a/frontend/tests/rectification-collection-question-pool.test.ts +++ b/frontend/tests/rectification-collection-question-pool.test.ts @@ -11,6 +11,7 @@ import { pendingTargetedYearDomain, preciseGapNarration, rangeNarrowHint, + remainingCandidatesLine, remainingSplitLayers, targetedCollectPool, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; @@ -194,3 +195,36 @@ test("targeted collect names remaining split layers without inferred years", () target_kind: "targeted:relationship", }], evidence), "relationship"); }); + +test("remaining candidates line uses credible range when it differs from active minute span", () => { + const splitTimes = ["04:50", "05:06"] as const; + const credibleRange = ["04:48", "05:07"] as const; + const examples = ["收入明显变过"]; + assert.match( + remainingCandidatesLine(splitTimes, 5, examples, credibleRange) ?? "", + /现在还剩 04:48–05:07 里 5 个候选/, + ); + assert.match( + remainingCandidatesLine(splitTimes, 5, examples, null) ?? "", + /现在还剩 04:50–05:06 里 5 个候选/, + ); + const layers = remainingSplitLayers({ + transitions: [ + { layer: "d9", at: "04:52" }, + { layer: "d2", at: "05:00" }, + ], + activeTimes: ["04:50", "04:53", "04:59", "05:03", "05:06"], + }); + const evidence = [educationStart, educationEnd, { + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + summary: "入职实习", + }]; + const followup = targetedCollectFollowup(layers, evidence, [], splitTimes, 5, credibleRange); + assert.match(followup?.spoken_prompt ?? "", /现在还剩 04:48–05:07 里 5 个候选/); + assert.match(rangeNarrowHint(layers, evidence, [], splitTimes, 5, credibleRange), /现在还剩 04:48–05:07 里 5 个候选/); +}); diff --git a/frontend/tests/rectification-spoken-orphan-20260914.test.ts b/frontend/tests/rectification-spoken-orphan-20260914.test.ts new file mode 100644 index 00000000..798b6386 --- /dev/null +++ b/frontend/tests/rectification-spoken-orphan-20260914.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { slimDecisionReceipt } from "../src/lib/rectification-agentic/v9/case-receipt-projection.ts"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { TARGETED_YEAR_PROMPT } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; +import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { + attachQuestionsToTurns, + questionIsAnswered, +} from "../src/lib/rectification-agentic/v9/turn-question.ts"; +import { + interviewChoiceCardUnavailable, + rectificationQuestionGapState, +} from "../src/lib/rectification-surface-state.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + activeFocusFixture, + candidateSnapshotFixture, + conversationSummaryFixture, + dossierFixture, +} from "./rectification-v9-test-support.ts"; + +const YEAR_FOCUS_ID = "acacacac-acac-4cac-8cac-acacacacacac"; +const CHOICE_FOCUS_ID = "adadadad-adad-4dad-8dad-adadadadadad"; + +function spokenYearFocus() { + return { + id: FOCUS_ID, + caseId: CASE_ID, + questionId: "collect:targeted:finance:year", + intent: "collect_method_evidence", + targetEvidenceId: null, + targetDomain: "finance", + targetKind: "targeted:finance:year", + expectedAnswerSchema: { + collect: true, + prompt: TARGETED_YEAR_PROMPT, + collect_kind: "targeted:finance:year", + }, + status: "active" as const, + askedAt: "2026-09-14T00:00:00.000Z", + resolvedAt: null, + askedTurnId: null, + answerOption: null, + }; +} + +test("spoken year follow-up without asked_turn_id hangs on the last assistant turn", () => { + const focus = spokenYearFocus(); + const current = projectCurrentQuestion(focus); + assert.equal(current?.kind, "collect_spoken"); + assert.equal(current?.prompt, "大概哪年几月?"); + assert.equal(current?.question_id, "collect:targeted:finance:year"); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: current?.kind, + hasChoiceCard: false, + questionId: current?.question_id, + }), + false, + ); + const attached = attachQuestionsToTurns( + [{ id: TURN_ID, role: "assistant" as const, text: "记下了。" }], + [focus], + ); + assert.equal(attached[0]?.question?.kind, "collect_spoken"); + assert.equal(attached[0]?.question?.prompt, "大概哪年几月?"); + assert.equal(attached[0]?.question?.question_id, "collect:targeted:finance:year"); + assert.equal(attached[0]?.question?.focus_id, FOCUS_ID); + const live = Boolean( + attached[0]?.question + && current + && attached[0].question.focus_id === current.focus_id + && !questionIsAnswered(attached[0].question), + ); + assert.equal(live, true); + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: live, + questionMissing: false, + questionLoadFailed: false, + questionPersisted: true, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: 0, + }), + "idle", + ); +}); + +test("hanging spoken focus does not replace a question already on the last assistant turn", () => { + const prompt = "结过婚或订过婚吗?"; + const attached = attachQuestionsToTurns( + [{ id: TURN_ID, role: "assistant" as const, text: "记下了,这方面先跳过。" }], + [ + { + id: CHOICE_FOCUS_ID, + caseId: CASE_ID, + questionId: "collect:targeted:relationship", + intent: "collect_method_evidence", + targetEvidenceId: null, + targetDomain: "relationship", + targetKind: "targeted:relationship", + expectedAnswerSchema: { + targeted_collect: true, + prompt, + choice: { + prompt, + option_a: "有过这件事", + option_b: "没有发生过", + option_c: "记不太清楚", + option_d: "这条先跳过", + options: [ + { key: "A", label: "有过这件事", answer_class: "yes" }, + { key: "B", label: "没有发生过", answer_class: "no" }, + { key: "C", label: "记不太清楚", answer_class: "unsure" }, + { key: "D", label: "这条先跳过", answer_class: "weak_yes" }, + ], + }, + }, + status: "active", + askedAt: "2026-09-14T00:00:00.000Z", + resolvedAt: null, + askedTurnId: TURN_ID, + answerOption: null, + }, + { + ...spokenYearFocus(), + id: YEAR_FOCUS_ID, + }, + ], + ); + assert.equal(attached[0]?.question?.kind, "choice"); + assert.equal(attached[0]?.question?.focus_id, CHOICE_FOCUS_ID); + assert.equal(attached[0]?.question?.question_id, "collect:targeted:relationship"); +}); + +test("engine representative time of an eliminated minute is not kept in model house tables", () => { + const inference = buildInferenceState({ + range_start: "04:48", + range_end: "05:14", + candidates: [ + { id: "04:53", time: "04:53", relative_support: 16 }, + { id: "05:06", time: "05:06", relative_support: 15 }, + { id: "05:14", time: "05:14", relative_support: 7 }, + ], + events: [], + probes: [], + }); + const fullInference = { + ...inference, + representative_time: "04:53", + credible_range: ["04:48", "05:07"] as const, + candidates: inference.candidates.map((candidate) => ( + candidate.time === "05:14" ? { ...candidate, status: "eliminated" as const } : candidate + )), + }; + const houseTables = { + "04:53": { time: "04:53", houses: Array(12).fill({ sign: "x" }) }, + "05:06": { time: "05:06", houses: Array(12).fill({ sign: "x" }) }, + "05:14": { time: "05:14", houses: Array(12).fill({ sign: "x" }) }, + }; + const receipt = { + inference_state: fullInference, + house_tables_by_time: houseTables, + }; + const dropped = parseV9CaseDossier(dossierFixture({ + latestResult: candidateSnapshotFixture({ + representativeTime: "05:14", + decisionReceipt: { inference_state: fullInference }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + expectedAnswerSchema: { collect: true, prompt: TARGETED_YEAR_PROMPT }, + }), + }), + })); + assert.ok(dropped); + const slim = slimDecisionReceipt(receipt, dropped!); + assert.deepEqual( + Object.keys(slim.house_tables_by_time as Record).sort(), + ["04:53", "05:06"], + ); + + const kept = parseV9CaseDossier(dossierFixture({ + latestResult: candidateSnapshotFixture({ + representativeTime: "05:14", + selectedTime: "05:14", + decisionReceipt: { inference_state: fullInference }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + expectedAnswerSchema: { collect: true, prompt: TARGETED_YEAR_PROMPT }, + }), + }), + })); + assert.ok(kept); + const slimKept = slimDecisionReceipt(receipt, kept!); + assert.deepEqual( + Object.keys(slimKept.house_tables_by_time as Record).sort(), + ["04:53", "05:06", "05:14"], + ); +});