diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f394e7..417072f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-14 — 探针耗尽后不再把判别题念进正文;有卡不再画裸题 + +生时校正里,带年月题问完、探针盖不上号时,不会再把同一句判别题写进正文让人打字。会改出定向补事点选卡。若快照里已经有选择题卡,界面不再只画一行题干。代表时间如果落在被淘汰候选或范围外,会记一条告警,排序逻辑本轮不动。Skill 版本不变。 + + ## 2026-09-13 — 定向补事存在题不再落成口述裸题 生时校正里,定向补事「有没有这件事」必须是四点选。若焦点被写成口述采集,刷新后不再只剩一行没有卡片的题干,会复原成可点的卡,或给出「接着问」修复入口。答完上一题后,下一问如果没落库,题干会写进本轮旁白,不会把计划中的 D9 题悄悄吞掉。年份追问仍是口述。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 25721894..18df14f9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10484,3 +10484,51 @@ - 相关记录:BUG-670、BUG-669、BUG-671、BUG-661 - 复发自:BUG-670 - 修复版本:待发布 + +## BUG-674 | 探针耗尽后判别题被念进正文,同一题反复问且答了不算数 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`persistNextInterviewAfterChoice`、`expectedAnswerSchemaFor` / `stampChoiceSchemaWithProbe`、`persistExhaustionCollect` +- 用户现象:答完带年月题后,正文反复出现同一句判别题(无 A/B/C/D)。用户打字回答「没有」只得到「记下了,这方面先跳过」,同一句再出现一次。 +- 触发条件:`prospective_probes` 为空,精度阶段仍给出带 `choice_frame` 的判别题;`stampChoiceSchemaWithProbe` 盖不上 `probe_id`,persist 返回 `invalid_choice_schema`。 +- 根因:上一单把「焦点没写成功」当成「题还在、只是没进消息」,把卡片题干拼进旁白。探针耗尽时这道题根本不该再问,也没有焦点可答。 +- 修复:判别题(`intent === distinguish_candidates`,或带 `choice_frame` 的非采集题)落不了库时改走 `persistExhaustionCollect`,不得把题干写进正文。采集题仍可进正文,但与上一条助手消息相同的题干会去重并转耗尽分支。 +- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts`。 +- 防复发:判别题落不了库时不得把题干写进正文;探针耗尽必须转定向补事卡。 +- 相关记录:BUG-673、BUG-652、BUG-661 +- 复发自:BUG-673(D3 把未落库题干拼进旁白) +- 修复版本:待发布 + +## BUG-675 | 快照已有 choice_card,界面仍画一行裸题 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`attachQuestionsToTurns`、`persisted_question` 渲染、`persistedQuestionSurface` +- 用户现象:`目前范围` 下面一行裸题(如「结过婚或订过婚吗?」),无头像、无选项、也没有「接着问」。 +- 触发条件:`current_question.kind === "choice"` 且 GET `choice_card` 非空,但焦点没有 `asked_turn_id`,卡片无法挂到消息上;`persisted_question` 只画 `prompt`。 +- 根因:`persisted_question` 从设计起只服务口述题。BUG-673 把定向题修成点选后,这个纯文本块成了新的裸题来源。 +- 修复:无 `asked_turn_id` 的活动选择题挂到最后一条助手消息,走既有消息内卡片。`persisted_question` 在有卡时渲染同一套 `RectificationChoiceCard`,不再只画一行字。 +- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts`;`rectification-surface-state.test.ts`。 +- 防复发:`current_question.kind === "choice"` 且有 `choice_card` 时必须渲染可点卡,不得只画题干。不得新增第二套卡片组件。 +- 相关记录:BUG-673、BUG-669、BUG-671 +- 复发自:BUG-673(数据形态修好后渲染仍走口述兜底) +- 修复版本:待发布 + +## BUG-676 | 代表时间取到被淘汰候选 + +- 状态:investigating +- 首次发现: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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-unstampable-probe-20260914.md b/docs/tasks/PROGRESS-rectification-unstampable-probe-20260914.md new file mode 100644 index 00000000..ebe9abfb --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-unstampable-probe-20260914.md @@ -0,0 +1,23 @@ +# 进度 · 探针耗尽、裸题与代表时间(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-unstampable-probe-20260914`(基于 `origin/staging` @ `9375012f`) +- 任务单:`docs/tasks/TASK-rectification-unstampable-probe-and-naked-card-20260914.md` +- BUG-674 / BUG-675 resolved;BUG-676 investigating +- 上一单 D3 已在任务书里标注推翻 + +## 完成 + +- T1:`persistNextInterviewAfterChoice` 尾部对判别题改走 `persistExhaustionCollect`;采集题保留题干进正文,但与上一条助手消息相同则去重。 +- T2:`attachQuestionsToTurns` 把无 `asked_turn_id` 的活动选择题挂到最后一条助手消息;`persisted_question` 有卡时渲染既有 `RectificationChoiceCard`。 +- T3:`warnRepresentativeTimeInconsistency`,不改排序。 +- T4:BUG_HISTORY、CHANGELOG、真机清单。 + +## 验收数字 + +- `./node_modules/.bin/tsc --noEmit`:0 错 +- `npm run lint`:0 error(120 warning,与基线同类,无新增 error) +- 相关单测 93/93:`rectification-unstampable-probe-20260914.test.ts` 8/8,加上 collect-prompt / question-in-message / surface-state / targeted-spoken / targeted-card-live / answer-choice +- `npm test`:2985 tests,2818 pass / 153 fail / 14 skip。失败项是本机 Windows 基线(Caddy/CRLF、Docker、bash、workflow YAML、Skill SHA),新增用例均绿,没有新的校正产品失败 +- `next build`:本工作树 `frontend/node_modules` 是 junction,Turbopack 拒绝软链;`--webpack` 编译成功后在收集 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 568b511c..0c20c563 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-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` | +| `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` | | `TASK-rectification-targeted-collect-spoken-focus-20260913.md` | `PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md` | **P0**:定向补事题以采集型 schema 存进焦点,前端只能裸画一行题干(无头像无卡无修复入口),本轮计划的 D9 题被静默吞掉;BUG-670 的识别条件只认 `targeted_collect` 章,复原不了(BUG-673,复发自 BUG-670) | 待验收 | `codex/rectification-targeted-collect-spoken-focus-20260913` | diff --git a/docs/testing/rectification-unstampable-probe-20260914.md b/docs/testing/rectification-unstampable-probe-20260914.md new file mode 100644 index 00000000..385661bf --- /dev/null +++ b/docs/testing/rectification-unstampable-probe-20260914.md @@ -0,0 +1,20 @@ +# 探针耗尽、裸题与代表时间:真机清单(2026-09-14) + +分支 `codex/rectification-unstampable-probe-20260914`。本环境无 staging 登录态。 + +## BUG-674 + +进入当时那个校正会话,或再走一遍「答完带年月题、探针池空」。 + +- [ ] 正文不再反复出现同一句「2023 年 5 月前后,有没有开始一段认真关系…」且没有选项。 +- [ ] 下一问是定向补事四选一卡,或诚实说明为什么现在没有下一题。 +- [ ] 打字回答「没有」不会让同一句判别题再出现一次。 + +## BUG-675 + +- [ ] 「结过婚或订过婚吗?」或「家里添过丁或长辈住过院吗?」出现时带四个选项,可点。 +- [ ] 刷新后仍是卡,不是一行裸题,也不应只剩「再说一件带年月的事就能继续」。 + +## BUG-676 + +- [ ] 打开当时那轮的 receipt(脱敏):记下 `inference_state.candidates` 每条的 `id` / `time` / `status` / `probability` / `posterior_score`,以及 `credible_range`。把这段交给修复单,不要改排序。 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 718f9fc8..4e747421 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -69,6 +69,7 @@ import { caseStageFromSnapshot, interviewCollectWaiting, interviewChoiceCardUnavailable, + persistedQuestionSurface, interviewStopReasonFromSnapshot, interviewSessionOutcomeFromSnapshot, type RectificationCaseSnapshotPayload, @@ -1868,7 +1869,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {questionGap === "persisted_question" && currentQuestion?.prompt && (
-

{currentQuestion.prompt}

+ {persistedQuestionSurface({ + questionKind: currentQuestion.kind, + hasChoiceCard: Boolean(choiceCard), + questionPersisted: true, + }) === "choice_card" && choiceCard ? ( + <> +

{currentQuestion.prompt}

+ + + ) : ( +

{currentQuestion.prompt}

+ )}
)} diff --git a/frontend/src/lib/rectification-agentic/core/build-state.ts b/frontend/src/lib/rectification-agentic/core/build-state.ts index 334f790b..7b5130b8 100644 --- a/frontend/src/lib/rectification-agentic/core/build-state.ts +++ b/frontend/src/lib/rectification-agentic/core/build-state.ts @@ -2,6 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco import type { TransitionSignLookup } from "./sign-from-transitions.ts"; import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts"; import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts"; +import { warnRepresentativeTimeInconsistency } from "./representative-time-guard.ts"; import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts"; import { entropyFromScores, normalizeScores } from "./entropy.ts"; import { selectHighestGainProbe } from "./select-probe.ts"; @@ -197,6 +198,14 @@ export function buildInferenceState(input: { ...(transitions ? { transitions } : {}), }; const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed }); + const lastRound = rounds.at(-1) ?? null; + warnRepresentativeTimeInconsistency({ + representativeTime: decision.representative_time, + eliminatedIds: lastRound?.eliminated_ids, + credibleRange: decision.credible_range ?? draft.credible_range, + winnerId: lastRound?.winner_id, + scoresAfter: lastRound?.scores_after, + }); return { ...draft, phase: phaseFor(decision.result_status, draft.phase), diff --git a/frontend/src/lib/rectification-agentic/core/representative-time-guard.ts b/frontend/src/lib/rectification-agentic/core/representative-time-guard.ts new file mode 100644 index 00000000..5ed38b91 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/representative-time-guard.ts @@ -0,0 +1,77 @@ +export type RepresentativeTimeGuardInput = Readonly<{ + representativeTime: string | null | undefined; + eliminatedIds?: readonly string[] | null; + credibleRange?: readonly string[] | null; + winnerId?: string | null; + scoresAfter?: Readonly> | null; +}>; + +export type RepresentativeTimeGuardResult = Readonly<{ + ok: boolean; + inEliminated: boolean; + outsideRange: boolean; +}>; + +function clockMinutes(value: string): number | null { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim()); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + +function outsideCredibleRange(time: string, range: readonly string[]): boolean { + if (range.length === 0) return false; + if (range.includes(time)) return false; + if (range.length < 2) return true; + const start = clockMinutes(range[0] ?? ""); + const end = clockMinutes(range[range.length - 1] ?? ""); + const point = clockMinutes(time); + if (start === null || end === null || point === null) return true; + if (start <= end) return point < start || point > end; + return point < start && point > end; +} + +function scoreExtremes(scores: Readonly> | null | undefined): { + max?: readonly [string, number]; + min?: readonly [string, number]; +} { + let max: readonly [string, number] | undefined; + let min: readonly [string, number] | undefined; + for (const [time, score] of Object.entries(scores ?? {})) { + if (!Number.isFinite(score)) continue; + if (!max || score > max[1]) max = [time, score]; + if (!min || score < min[1]) min = [time, score]; + } + return { max, min }; +} + +export function inspectRepresentativeTime(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult { + const time = typeof input.representativeTime === "string" ? input.representativeTime.trim() : ""; + if (!time) { + return { ok: true, inEliminated: false, outsideRange: false }; + } + const inEliminated = (input.eliminatedIds ?? []).includes(time); + const outsideRange = outsideCredibleRange(time, input.credibleRange ?? []); + return { + ok: !inEliminated && !outsideRange, + inEliminated, + outsideRange, + }; +} + +export function warnRepresentativeTimeInconsistency(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult { + const result = inspectRepresentativeTime(input); + if (result.ok) return result; + const extremes = scoreExtremes(input.scoresAfter); + console.warn(JSON.stringify({ + event: "rectification_representative_time_inconsistent", + winner_id: input.winnerId ?? null, + representative_time: input.representativeTime ?? null, + credible_range: input.credibleRange ?? null, + eliminated_ids: input.eliminatedIds ?? [], + scores_after_max: extremes.max ?? null, + scores_after_min: extremes.min ?? null, + in_eliminated: result.inEliminated, + outside_range: result.outsideRange, + })); + return result; +} diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index c0e88edb..77d22c56 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -924,6 +924,7 @@ export async function persistNextInterviewAfterChoice(input: { narrateAdopt?: AdoptNarrationWriter; skipRefresh?: boolean; continueTieBreakRound?: boolean; + followup?: MethodFollowup | null; }): Promise<{ hostNarration: string; choiceReady: boolean; @@ -1010,7 +1011,7 @@ export async function persistNextInterviewAfterChoice(input: { } } const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId); - const followup = interviewToPersist(plan); + const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan); if (shouldSkipFollowupPersist({ canAdopt: nextAction.can_adopt, nextAction: nextAction.type, @@ -1048,6 +1049,15 @@ export async function persistNextInterviewAfterChoice(input: { followup: null, }; } + const exhaustion = { + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier: liveDossier, + decision, + decisionReceipt: liveDossier.latestResult?.decisionReceipt, + askedTurnId: input.askedTurnId ?? null, + }; const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, @@ -1068,6 +1078,9 @@ export async function persistNextInterviewAfterChoice(input: { }; } if (open?.kind === "collect_spoken" && open.prompt) { + if (stemAlreadyInLastAssistant(open.prompt, input.dossier)) { + return persistExhaustionCollect(exhaustion); + } return { hostNarration: open.prompt, choiceReady: false, @@ -1078,20 +1091,15 @@ export async function persistNextInterviewAfterChoice(input: { }; } if (followup && isTargetedCollectFollowup(followup) && followup.choice_frame) { - return persistExhaustionCollect({ - accounting: input.accounting, - userId: input.userId, - caseId: input.caseId, - dossier: liveDossier, - decision, - decisionReceipt: liveDossier.latestResult?.decisionReceipt, - askedTurnId: input.askedTurnId ?? null, - }); + return persistExhaustionCollect(exhaustion); } if (followup?.intent === "collect_method_evidence" && followup.choice_frame) { const spokenFollowup = spokenCollectFallbackFollowup(followup); const spoken = spokenFollowupForUser(spokenFollowup, liveDossier.evidence); if (spoken) { + if (stemAlreadyInLastAssistant(spoken, input.dossier)) { + return persistExhaustionCollect(exhaustion); + } const fallback = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, @@ -1121,17 +1129,12 @@ export async function persistNextInterviewAfterChoice(input: { spoken && (persistedFocus.status === "created" || persistedFocus.status === "already_open") ) { + if (stemAlreadyInLastAssistant(spoken, input.dossier)) { + return persistExhaustionCollect(exhaustion); + } return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup }; } - return persistExhaustionCollect({ - accounting: input.accounting, - userId: input.userId, - caseId: input.caseId, - dossier: liveDossier, - decision, - decisionReceipt: liveDossier.latestResult?.decisionReceipt, - askedTurnId: input.askedTurnId ?? null, - }); + return persistExhaustionCollect(exhaustion); } if (!followup) { if (input.dossier.case.acceptedTime) { @@ -1141,15 +1144,13 @@ export async function persistNextInterviewAfterChoice(input: { followup: null, }; } - return persistExhaustionCollect({ - accounting: input.accounting, - userId: input.userId, - caseId: input.caseId, - dossier: liveDossier, - decision, - decisionReceipt: liveDossier.latestResult?.decisionReceipt, - askedTurnId: input.askedTurnId ?? null, - }); + return persistExhaustionCollect(exhaustion); + } + if ( + followup.intent === "distinguish_candidates" + || (followup.choice_frame && followup.intent !== "collect_method_evidence") + ) { + return persistExhaustionCollect(exhaustion); } const spoken = (spokenFollowupForUser(followup) ?? "").trim(); if (!spoken) { @@ -1160,15 +1161,10 @@ export async function persistNextInterviewAfterChoice(input: { followup: null, }; } - return persistExhaustionCollect({ - accounting: input.accounting, - userId: input.userId, - caseId: input.caseId, - dossier: liveDossier, - decision, - decisionReceipt: liveDossier.latestResult?.decisionReceipt, - askedTurnId: input.askedTurnId ?? null, - }); + return persistExhaustionCollect(exhaustion); + } + if (stemAlreadyInLastAssistant(spoken, input.dossier)) { + return persistExhaustionCollect(exhaustion); } return { hostNarration: spoken, @@ -1178,6 +1174,23 @@ export async function persistNextInterviewAfterChoice(input: { }; } +function stemAlreadyInLastAssistant( + stem: string, + dossier: { turns?: readonly { role?: string; text?: string | null }[] }, +): boolean { + const needle = stem.trim(); + if (needle.length < 8) return false; + const turns = dossier.turns ?? []; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn?.role !== "assistant") continue; + const text = typeof turn.text === "string" ? turn.text : ""; + if (!text.trim()) continue; + return text.includes(needle); + } + return false; +} + export async function requestTieBreakPersonality(input: { accounting: AccountingClient; userId: string; @@ -1747,6 +1760,7 @@ async function persistExhaustionCollect(input: { choiceReady: boolean; hostNarration: string; focus?: ConversationFocus | null; + followup?: MethodFollowup | null; terminalNote?: boolean; }> { let dossier = input.dossier as DecisionDossier; @@ -1833,6 +1847,7 @@ async function persistExhaustionCollect(input: { choiceReady: true, hostNarration: targetedCollectHostNarration(followup, open.prompt), focus: persistedFocus.focus, + followup, }; } const spoken = followup.choice_frame 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 57a02e67..51acbe32 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -26,6 +26,7 @@ import { type RectificationDecision, } from "../core/rectification-decision.ts"; import { evaluateCandidateSeparation } from "../core/candidate-separation.ts"; +import { warnRepresentativeTimeInconsistency } from "../core/representative-time-guard.ts"; import type { ConflictProbe, InferenceState } from "../core/types.ts"; import { askedDiscriminatorKeys, @@ -1031,18 +1032,19 @@ export function overlayPublicDecision( completionStatus: ReturnType["completion_status"]; } { const fields = publicDecisionFields(decision); + const receipt = (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null; + const inference = previousInferenceFromReceipt(receipt); + const round = inference?.last_inference_round ?? null; const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]); const inconsistent = !projection.consistent; - const ceiling = engineCapabilityCeilingFromReceipt( - (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null, - ); + const ceiling = engineCapabilityCeilingFromReceipt(receipt); const canAdopt = !inconsistent && fields.can_adopt && ceiling.acceptanceAllowed; const selectionAllowed = !inconsistent && fields.selection_allowed && ceiling.selectionAllowed; const proposeAllowed = !inconsistent && fields.propose_allowed && ceiling.proposeAllowed; const canConfirmExactMinute = !inconsistent && fields.can_confirm_exact_minute && ceiling.confirmationAllowed; - return { + const overlaid = { ...snapshot, ...fields, can_adopt: canAdopt, @@ -1062,4 +1064,12 @@ export function overlayPublicDecision( validated: fields.validated, completionStatus: fields.completion_status, }; + warnRepresentativeTimeInconsistency({ + representativeTime: overlaid.representative_time, + eliminatedIds: round?.eliminated_ids, + credibleRange: overlaid.credible_range ?? inference?.credible_range, + winnerId: round?.winner_id, + scoresAfter: round?.scores_after, + }); + return overlaid; } diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index b4e8802b..67bcf43d 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -104,10 +104,20 @@ export function attachQuestionsToTurns { const byTurn = new Map(); + const hanging: ConversationFocus[] = []; for (const focus of focuses) { - if (!focus.askedTurnId) continue; + if (!focus.askedTurnId) { + if (focus.status === "active" && turnQuestionKind(focus) === "choice") { + hanging.push(focus); + } + continue; + } byTurn.set(focus.askedTurnId, focus); } + const lastAssistant = [...turns].reverse().find((turn) => turn.role === "assistant"); + if (hanging[0] && lastAssistant && !byTurn.has(lastAssistant.id)) { + byTurn.set(lastAssistant.id, hanging[0]); + } return turns.map((turn) => { if (turn.role !== "assistant") return { ...turn, question: null }; const focus = byTurn.get(turn.id); diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index afc2b8e3..1bdb746f 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -347,6 +347,16 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn return null; } +export function persistedQuestionSurface(input: Readonly<{ + questionKind?: string | null; + hasChoiceCard: boolean; + questionPersisted: boolean; +}>): "none" | "spoken" | "choice_card" { + if (!input.questionPersisted) return "none"; + if (input.questionKind === "choice" && input.hasChoiceCard) return "choice_card"; + return "spoken"; +} + export function interviewChoiceCardUnavailable(input: Readonly<{ questionKind?: string | null; hasChoiceCard: boolean; diff --git a/frontend/tests/rectification-collect-prompt.test.ts b/frontend/tests/rectification-collect-prompt.test.ts index b38f5b66..bd752b09 100644 --- a/frontend/tests/rectification-collect-prompt.test.ts +++ b/frontend/tests/rectification-collect-prompt.test.ts @@ -89,7 +89,10 @@ 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/); - assert.match(attach, /if \(!focus\.askedTurnId\) continue/); + // 原值: if (!focus.askedTurnId) continue + // 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue + // 原因: BUG-675 焦点没有 asked_turn_id 时卡片仍要出现在消息上 + assert.match(attach, /if \(!focus\.askedTurnId\) \{/); }); test("stripQuestionSentences drops a rewritten trailing question and its tag", () => { diff --git a/frontend/tests/rectification-question-in-message.test.ts b/frontend/tests/rectification-question-in-message.test.ts index f472b0be..6333e8c8 100644 --- a/frontend/tests/rectification-question-in-message.test.ts +++ b/frontend/tests/rectification-question-in-message.test.ts @@ -77,7 +77,10 @@ test("GET turns attach question by asked_turn_id and never by timestamps", () => assert.match(caseProjection, /question: turn\.question/); assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/); const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); - assert.match(attach, /if \(!focus\.askedTurnId\) continue/); + // 原值: if (!focus.askedTurnId) continue + // 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue + // 原因: BUG-675 + assert.match(attach, /if \(!focus\.askedTurnId\) \{/); assert.doesNotMatch(attach, /askedAt|createdAt/); }); diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts index 7a054dd2..45568c61 100644 --- a/frontend/tests/rectification-surface-state.test.ts +++ b/frontend/tests/rectification-surface-state.test.ts @@ -16,6 +16,7 @@ import { rectificationQuestionRetryActive, interviewCollectWaiting, interviewChoiceCardUnavailable, + persistedQuestionSurface, interviewStopReasonFromSnapshot, RECTIFICATION_COLLECT_WAITING_PLACEHOLDER, rectificationReadonlyRangeCopy, @@ -111,6 +112,17 @@ test("question gap: nothing is shown while busy, readonly, regenerating, or for assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle"); }); +test("a persisted choice with a live card is not the spoken-only prompt surface", () => { + assert.equal( + persistedQuestionSurface({ + questionKind: "choice", + hasChoiceCard: true, + questionPersisted: true, + }), + "choice_card", + ); +}); + test("question gap: a persisted focus question is shown instead of preparing", () => { assert.equal( rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }), diff --git a/frontend/tests/rectification-unstampable-probe-20260914.test.ts b/frontend/tests/rectification-unstampable-probe-20260914.test.ts new file mode 100644 index 00000000..00a697e5 --- /dev/null +++ b/frontend/tests/rectification-unstampable-probe-20260914.test.ts @@ -0,0 +1,575 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyRectificationChoice, persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; +import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + expectedAnswerSchemaFor, + persistServerOwnedFocus, + stableFollowupQuestionId, +} from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; +import { persistedQuestionSurface } from "../src/lib/rectification-surface-state.ts"; +import { + inspectRepresentativeTime, + warnRepresentativeTimeInconsistency, +} from "../src/lib/rectification-agentic/core/representative-time-guard.ts"; +import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; +import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + CASE_ID, + FOCUS_ID, + SESSION_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + candidateSnapshotFixture, + computeFixture, + conversationSummaryFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +const STEM = "2023 年 5 月前后,有没有开始一段认真关系、分手或结婚?"; +const OPTIONS = [ + { label: "明确发生且时间吻合", answer_class: "yes" as const }, + { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, + { label: "明确没有发生", answer_class: "no" as const }, + { label: "这段记不清楚", answer_class: "unsure" as const }, +]; + +function emptyInference() { + return { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: "04:48-05:07", + revision: 6, + phase: "discrimination", + result_status: "discriminating", + range_start: "04:48", + range_end: "05:07", + candidates: [ + { + id: "04:53", + time: "04:53", + cluster_range: ["04:48", "05:07"] as const, + prior_score: 16, + posterior_score: 16, + probability: 0.4, + status: "active" as const, + rank: 1, + strong_conflict_count: 0, + }, + { + id: "05:06", + time: "05:06", + cluster_range: ["04:48", "05:07"] as const, + prior_score: 15, + posterior_score: 15, + probability: 0.35, + status: "active" as const, + rank: 2, + strong_conflict_count: 0, + }, + ], + events: [], + probes: [], + answered_probes: [], + rounds: [], + entropy: 1, + representative_time: "04:53", + credible_range: ["04:48", "05:07"] as const, + }; +} + +const WINDOW_SCAN = { + scanned: true, + d9_lagna_count: 2, + d10_lagna_count: 2, + d4_lagna_count: 2, + d9_candidates_differ: true, + d10_candidates_differ: true, + d4_candidates_differ: true, +}; + +function distinguishFollowup(): MethodFollowup { + const frame = buildChoiceFrame({ + method_id: "d9_relationship", + ask_theme: "dated_event", + domain: "relationship", + user_prompt_hint: "ask", + }, { + probes: [{ + year: 2023, + year_label: "2023 年 5 月前后", + domain: "relationship", + event_family: "开始一段认真关系、分手或结婚", + source: "dasha_boundary", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: STEM, + role: "distinguish", + information_gain: 0.4, + semantic_key: "relationship.2023.05.dasha_boundary", + style_options: OPTIONS, + }], + }); + assert.ok(frame); + return { + method_id: "d9_relationship", + intent: "distinguish_candidates", + ask_theme: "dated_event", + domain: "relationship", + kind_hint: "relationship_change", + user_prompt_hint: "ask", + must_not_label: false, + choice_frame: frame, + source: "precision_stage", + information_gain: 0.4, + semantic_key: "relationship.2023.05.dasha_boundary", + probe_year: 2023, + year_label: "2023 年 5 月前后", + candidate_ids: ["04:48", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:48"], conflicts: ["05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["04:48"] }, + ], + }; +} + +const DATED_EVIDENCE = [ + { + id: "e-edu", + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + eventKind: "education_start", + summary: "2016年9月上大学", + }, + { + id: "e-career", + status: "confirmed" as const, + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + summary: "2020年4月入职", + }, + { + id: "e-rel", + status: "confirmed" as const, + domain: "relationship", + datePrecision: "year" as const, + occurredFrom: "2018-01-01", + occurredTo: "2018-12-31", + eventKind: "relationship_start", + summary: "2018年恋爱", + }, + { + id: "e-move", + status: "confirmed" as const, + domain: "relocation", + datePrecision: "year" as const, + occurredFrom: "2015-01-01", + occurredTo: "2015-12-31", + eventKind: "relocation", + summary: "2015年搬家", + }, +]; + +const PERSIST_EVIDENCE = DATED_EVIDENCE.filter((row) => row.domain !== "relationship"); + +function focusAccounting() { + return fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_compute: () => computeFixture(), + get_agentic_rectification_case_dossier: () => snakeDossier(), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id ?? null, + target_domain: args.p_target_domain ?? null, + target_kind: args.p_target_kind ?? null, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-14T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }, + idempotent: false, + }), + resolve_agentic_rectification_conversation_focus: () => ({ + focus: { id: FOCUS_ID, status: "resolved" }, + idempotent: false, + }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + apply_agentic_rectification_choice_action: (_fn, args) => ({ + action_id: args.p_action_id, + status: "applied", + idempotent: false, + question_id: args.p_question_id, + option_id: args.p_option_id, + probe_id: "p-cd", + revision: Number(args.p_expected_revision ?? 6) + 1, + source_quote: args.p_source_quote, + derived_context: args.p_derived_context, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + }); +} + +function snakeEvidence(rows: typeof DATED_EVIDENCE = DATED_EVIDENCE) { + return rows.map((row) => ({ + id: row.id, + status: row.status, + domain: row.domain, + date_precision: row.datePrecision, + occurred_from: row.occurredFrom, + occurred_to: row.occurredTo, + event_kind: row.eventKind, + summary: row.summary, + })); +} + +function snakeDossier(overrides: Parameters[0] = {}) { + return dossierFixture({ + evidence: snakeEvidence(PERSIST_EVIDENCE), + evidenceCount: PERSIST_EVIDENCE.length, + stage: "minute", + candidateRange: { start_time: "04:48", end_time: "05:07" }, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "04:53", + candidates: [ + { candidate_id: "04:53", rank: 1, time: "04:53", relative_support: 16 }, + { candidate_id: "05:06", rank: 2, time: "05:06", relative_support: 15 }, + ], + decisionReceipt: { + inference_state: emptyInference(), + prospective_probes: [], + precision_stage: { current: "d9_refine" }, + discriminating_event_probes: [], + window_scan: WINDOW_SCAN, + }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + expectedAnswerSchema: { + prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?", + probe_id: "p-cd", + semantic_key: "career.2015", + scoring: true, + choice: { + prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?", + option_a: "是,大概就在那段时间", + option_b: "有类似,但年份不对或不够重大", + option_c: "没有明显发生", + option_d: "不记得 / 不确定", + options: [ + { key: "A", label: "是,大概就在那段时间", answer_class: "yes" }, + { key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" }, + { key: "C", label: "没有明显发生", answer_class: "no" }, + { key: "D", label: "不记得 / 不确定", answer_class: "unsure" }, + ], + }, + }, + }), + }), + turns: [ + { + id: TURN_ID, + role: "assistant", + text: "已记录,范围收到 04:48–05:07。", + status: "completed", + }, + ], + ...overrides, + }); +} + +function camelDossier() { + return { + evidence: PERSIST_EVIDENCE, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: [], + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + decisionReceipt: { + inference_state: emptyInference(), + prospective_probes: [], + precision_stage: { current: "d9_refine" }, + discriminating_event_probes: [], + window_scan: WINDOW_SCAN, + }, + selectionAllowed: true, + candidates: [ + { time: "04:53", rank: 1, relativeSupport: 16 }, + { time: "05:06", rank: 2, relativeSupport: 15 }, + ], + }, + case: { + acceptedTime: null, + status: "collecting_evidence", + candidateRange: { start_time: "04:48", end_time: "05:07" }, + stage: "minute" as const, + }, + turns: [ + { role: "assistant" as const, text: "已记录,范围收到 04:48–05:07。" }, + ], + }; +} + +test("empty probe pool cannot stamp a scoring distinguish schema", () => { + const followup = distinguishFollowup(); + const schema = expectedAnswerSchemaFor( + followup.choice_frame!, + stableFollowupQuestionId(followup), + { inference_state: emptyInference() }, + followup, + ); + assert.equal(schema, null); +}); + +test("unstampable distinguish persist is invalid_choice_schema", async () => { + const accounting = focusAccounting(); + const result = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: { inference_state: emptyInference() }, + followup: distinguishFollowup(), + }); + assert.equal(result.status, "invalid_choice_schema"); +}); + +test("persistNextInterviewAfterChoice does not speak an unstampable distinguish stem", async () => { + const followup = distinguishFollowup(); + const stem = followup.choice_frame?.prompt ?? STEM; + const accounting = focusAccounting(); + const dossier = camelDossier(); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const first = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: emptyInference() as never, + nextAction: publicNextAction(decision), + decision, + birthDate: "1997-08-08", + skipRefresh: true, + followup, + }); + assert.equal(first.hostNarration.includes(stem), false, first.hostNarration); + assert.notEqual(first.followup?.intent, "distinguish_candidates"); + assert.ok( + first.choiceReady === true || first.followup?.intent === "collect_method_evidence", + JSON.stringify({ choiceReady: first.choiceReady, intent: first.followup?.intent, host: first.hostNarration }), + ); + const second = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier: { + ...dossier, + turns: [{ role: "assistant" as const, text: stem }], + }, + decisionState: emptyInference() as never, + nextAction: publicNextAction(decision), + decision, + birthDate: "1997-08-08", + skipRefresh: true, + followup, + }); + assert.equal(second.hostNarration.includes(stem), false, second.hostNarration); +}); + +test("collect stem already in the last assistant turn is not spoken again", async () => { + const stem = "请再补充一件带年月的搬家或长期住到外地的经历。"; + const followup: MethodFollowup = { + method_id: "dasha_events", + intent: "collect_method_evidence", + ask_theme: "dated_event", + domain: "relocation", + kind_hint: "home_change", + user_prompt_hint: "ask", + must_not_label: false, + choice_frame: null, + spoken_prompt: stem, + source: "method_coverage", + }; + const accounting = focusAccounting(); + const dossier = { + ...camelDossier(), + turns: [{ role: "assistant" as const, text: `记下了。\n\n${stem}` }], + }; + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const result = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: emptyInference() as never, + nextAction: publicNextAction(decision), + decision, + birthDate: "1997-08-08", + skipRefresh: true, + followup, + }); + assert.equal(result.hostNarration.includes(stem), false, result.hostNarration); +}); + +test("applyRectificationChoice narration does not re-ask an unstampable distinguish stem", async () => { + const followup = distinguishFollowup(); + const stem = followup.choice_frame?.prompt ?? STEM; + const accounting = focusAccounting(); + const applied = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: CHOICE_ACTION, + focusId: FOCUS_ID, + questionId: "question-1", + probeId: "p-cd", + optionId: "C", + expectedRevision: 6, + }); + assert.equal(applied.applied, true); + assert.equal(applied.narration.includes(stem), false, applied.narration); + const nextMeaning = JSON.stringify(applied.nextUserAction ?? {}); + assert.equal(nextMeaning.includes(stem), false, nextMeaning); +}); + +test("active choice focus without asked_turn_id hangs on the last assistant turn", () => { + const prompt = "结过婚或订过婚吗?"; + const attached = attachQuestionsToTurns( + [ + { id: TURN_ID, role: "assistant" as const, text: "已记录,范围收到 04:48–05:07。" }, + ], + [{ + id: 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: null, + answerOption: null, + }], + ); + assert.equal(attached[0]?.question?.kind, "choice"); + assert.equal(attached[0]?.question?.focus_id, FOCUS_ID); + assert.equal(attached[0]?.question?.options?.length, 4); + assert.equal(attached[0]?.question?.prompt, prompt); +}); + +test("persisted question with a live choice card is not the spoken-only surface", () => { + assert.equal( + persistedQuestionSurface({ + questionKind: "choice", + hasChoiceCard: true, + questionPersisted: true, + }), + "choice_card", + ); + assert.equal( + persistedQuestionSurface({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionPersisted: true, + }), + "spoken", + ); + assert.equal( + persistedQuestionSurface({ + questionKind: "choice", + hasChoiceCard: true, + questionPersisted: false, + }), + "none", + ); +}); + +test("representative time in eliminated ids or outside the range warns once each", () => { + const lines: string[] = []; + const original = console.warn; + console.warn = (value: unknown) => { + lines.push(String(value)); + }; + try { + const eliminated = warnRepresentativeTimeInconsistency({ + representativeTime: "05:14", + eliminatedIds: ["05:14", "05:08"], + credibleRange: ["04:48", "05:07"], + winnerId: "04:53", + scoresAfter: { "04:53": 16, "05:14": 7 }, + }); + assert.equal(eliminated.ok, false); + assert.equal(eliminated.inEliminated, true); + const outside = warnRepresentativeTimeInconsistency({ + representativeTime: "05:14", + eliminatedIds: [], + credibleRange: ["04:48", "05:07"], + winnerId: "04:53", + scoresAfter: { "04:53": 16, "05:14": 7 }, + }); + assert.equal(outside.ok, false); + assert.equal(outside.outsideRange, true); + const healthy = warnRepresentativeTimeInconsistency({ + representativeTime: "04:53", + eliminatedIds: ["05:14"], + credibleRange: ["04:48", "05:07"], + winnerId: "04:53", + scoresAfter: { "04:53": 16, "05:14": 7 }, + }); + assert.equal(healthy.ok, true); + assert.equal(inspectRepresentativeTime({ + representativeTime: "04:53", + eliminatedIds: ["05:14"], + credibleRange: ["04:48", "05:07"], + }).ok, true); + } finally { + console.warn = original; + } + assert.equal(lines.filter((line) => line.includes("rectification_representative_time_inconsistent")).length, 2); + assert.match(lines[0] ?? "", /"representative_time":"05:14"/); + assert.doesNotMatch(lines.join("\n"), /birth|email|jwt/i); +});