From 66f63c76436543f4d303f6918ad0b1d44989510b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 11 Sep 2026 14:33:39 +0800 Subject: [PATCH] fix(rectification): deliver range when dated discriminator pool is empty (BUG-651/652) When dated choice probes are exhausted after the training gate, stop treating yearless D9/D10 cards as the next discriminator and persist a range carrier in the same answer transaction. Co-authored-by: Cursor --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 32 + ...tion-probe-pool-exhausted-exit-20260911.md | 42 ++ .../rectification-scenarios-20260907.md | 13 + .../core/rectification-decision.ts | 28 +- .../rectification-agentic/v9/answer-choice.ts | 39 +- .../v9/decision-from-dossier.ts | 26 +- .../v9/method-followup.ts | 94 +-- .../rectification-agentic/v9/server-focus.ts | 21 + .../src/lib/rectification-surface-state.ts | 32 +- .../tests/agent-voice-copy-contract.test.ts | 5 + .../rectification-decide-next-action.test.ts | 13 +- ...tion-probe-pool-exhausted-20260911.test.ts | 647 ++++++++++++++++++ .../tests/rectification-surface-state.test.ts | 15 +- ...-yearless-probe-downgrade-20260909.test.ts | 17 +- 15 files changed, 895 insertions(+), 134 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-probe-pool-exhausted-exit-20260911.md create mode 100644 frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 495298ec..db112497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-11 — 带年月选择题问完后会给出当前范围 + +生时校正里,训练门已经打开、带年月的选择题问完后,会直接给出当前范围和三列对照卡,并补一句还可以再想起什么来收一截。不会停在「没有拿到下一个问题」,也不会先逼你答性格对照题。时间线有卡时写「选择题已问完,下面是当前范围」;材料还不够时才请再说一件带年月的事。Skill 版本不变。 + + ## 2026-09-11 — 职业题答出的年月会记成可评分的工作经历 生时校正问「你平时主要做什么工作?」时,如果一并说出开始年月,会同时记下职业备注和一件可评分的入职经历,不再把年月抹掉。只说工种、没有年月时,仍只记备注、不拿它凑三件。开始年份改由毕业后第一份工作这类锚定题来问。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3bdeae5f..7abbfecd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10116,3 +10116,35 @@ - 相关记录:BUG-646、BUG-596、BUG-649 - 复发自:BUG-596(禁止第二条门槛 turn 后缺口没并入正文) - 修复版本:待发布 + +## BUG-651 | 带年月区分题池空必须交付区间,不得改问性格题 + +- 状态:resolved +- 首次发现:2026-09-11 +- 最近更新:2026-09-11 +- 影响面:`decideRectification`、`discriminatorProbeIfFollowupCanAsk`、`buildMethodFollowupPlan` +- 用户现象:训练门已开、带年月选择题问完后,决策仍是 `discriminate_candidates`,界面没有结果卡。 +- 触发条件:训练门开、`acceptance_allowed` 为真、带年月探针无可问,只剩 D9/D10 `varga_style` 或月宿边界题。 +- 根因:`!separation.sufficient` 把无年月性格题当成下一道区分题;`buildMethodFollowupPlan` 在带年月池空时走 `firstRenderableYearlessFollowup` / 性格卡。重设计单 S3「探针池空 → 交付」以带年月题为准,性格题不得挡在结果前面。 +- 修复:probe 只认带年月探针;性格题 / 月宿一律 `yearless_deferred`,不再作为 `ask_candidate_discriminator`。池空时按 `canAdopt` 落到 `offer_provisional_range` / `adopt_representative`。本单按让步顺序先不问性格题,可选「再答两道参考题」入口另开一单。 +- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts`;`rectification-yearless-probe-downgrade-20260909.test.ts` 带年月池空不再出 D9。 +- 防复发:不得把 `varga_style` / `nakshatra_boundary` 加回带年月区分池(BUG-629);不得让性格题参与淘汰。 +- 相关记录:BUG-629、BUG-442、BUG-627、BUG-652 +- 复发自:BUG-629(性格题降权后仍被当成必答区分题) +- 修复版本:待发布 + +## BUG-652 | 答题事务有决策无载体时必须回落到交付或缺口句 + +- 状态:resolved +- 首次发现:2026-09-11 +- 最近更新:2026-09-11 +- 影响面:`persistNextInterviewIfIdle`、`persistNextInterviewAfterChoice`、`persistServerOwnedFocus`、时间线 `rectificationReadonlyRangeCopy` +- 用户现象:六道带年月选择题答完后,助手只回「已记录,范围没变…」,时间线写「选择题已问完,再补带年月的经历才会变」,界面「没有拿到下一个问题。」没有结果卡、没有采用入口、没有缺口句。 +- 触发条件:训练门已开、引擎已允许交付,决策仍是 `ask_candidate_discriminator`,但下一张区分卡没落下(persist 返回 skipped / invalid_choice_schema / 空正文)。 +- 根因:`persistNextInterviewIfIdle` 对 `ask_candidate_discriminator` / `ask_holdout_validation` 豁免了 `persistExhaustionCollect`。`persistNextInterviewAfterChoice` 在焦点不可渲染时用 `spokenFollowupForUser(followup) ?? fallback`,空串不触发兜底。时间线在没有结果卡时仍写「才会变」。 +- 修复:撤回 IfIdle 豁免;答题事务在无可渲染焦点且无正文时同一事务回落到 `persistExhaustionCollect`;persist 非 created/already_open 时 `console.warn` `rectification_discriminator_persist_skipped`;时间线有卡写「选择题已问完,下面是当前范围」,门关写「再说一件带年月的事就能继续」,其余「还在核对」。 +- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T3;`rectification-surface-state.test.ts` 时间线合同;`agent-voice-copy-contract.test.ts` 禁词「才会变」。 +- 防复发:决策有动作就必须有载体;不得靠客户端「接着问」补救。 +- 相关记录:BUG-651、BUG-627、BUG-442 +- 复发自:BUG-627(`ensureNonTerminalTurnExit` 只在点「接着问」时触发,不在答题事务里) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-probe-pool-exhausted-exit-20260911.md b/docs/tasks/PROGRESS-rectification-probe-pool-exhausted-exit-20260911.md new file mode 100644 index 00000000..b354de48 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-probe-pool-exhausted-exit-20260911.md @@ -0,0 +1,42 @@ +# 进度 · 带年月选择题问完后必须交付结果(2026-09-11) + +T0 回放(第六题答后)。在未改代码的 `1fa994ea` 上跑同一夹具:`nextAction=ask_candidate_discriminator`,`sessionOutcome=discriminate_candidates`,`decision.probe` 是 D10 性格题 `varga.d10.狮子座/处女座`(`choiceKind=varga_style`,`year=null`),`plan.next_followup` 为空,`persistServerOwnedFocus` 返回 `status=skipped`。这就是「有决策、无载体」。修复后打印 `t0_probe_pool_exhausted`: `nextAction=offer_provisional_range`,`semantic_key=null`,`choice_kind=null`,`persist_status=skipped`(空 followup)。同一事务落到交付旁白(含「如果还记得…范围还能再收一截」),`can_adopt=true`。 + +## 范围 + +- 分支:`codex/rectification-probe-pool-exhausted-exit-20260911`(基于 `origin/staging` @ `1fa994ea`) +- BUG-651:带年月区分池空即交付;性格题不再挡在结果前面(本单按让步先不问性格题) +- BUG-652:答题事务必须留下载体;时间线禁词「才会变」 +- 不改 `MIN_ACCEPTANCE_*`、`SCORE_DELTA`、收敛门;不开 `confirmation_allowed`;Skill 不升 + +## 完成 + +- T0 回放测试 +- T1 决策层只认带年月探针 +- T2 让步:性格题完全不问;可选入口另开一单 +- T3 IfIdle 豁免撤回 + AfterChoice 空正文回落 + persist skip warn +- T4 时间线文案 +- T5 BUG_HISTORY / CHANGELOG / 场景脚本 + +## 未做 + +- T2 卡下「还可以再答两道参考题微调排序」入口 + +## 验收 + +工作树内 `frontend/node_modules` 链到主仓。未 commit、未 push、未部署。真机走查留给部署后按场景 0b。 + +| 命令 | 结果 | +| --- | --- | +| `cd frontend && npx tsc --noEmit` | **0 error** | +| `cd frontend && npm run lint` | **0 error** / 119 warning(既有) | +| 相关套件 `tsx --test`(开工 5 文件 + T0 新文件 + surface / voice / answer-choice / range-offer) | **149 pass / 0 fail**;开工所列 5 文件断言数不降,新文件 +3 | +| `run_quality_gate.py --profile quick` | pytest **730 passed / 1 failed / 1 skipped**:`test_shadbala_endpoint_returns_ranked_planet_strength`(`timezone inference dependency unavailable`)。与 `PROGRESS-rectification-latent-audit-20260910` 同环境缺口,本单未改 Python | +| `cd frontend && ./node_modules/.bin/next build --webpack` | 退出 0;默认 Turbopack 仍拒 worktree 外 `node_modules` 软链。`/` 为 `○` Static。首页 JS gzip-9 **567845 B / 18 chunks**(近期同口径 occupation 单 572018 B,−0.73%) | + +断言改写(原值 / 新值 / 原因): + +- `dated distinguish empty and two unseparated candidates defers D9`:原值 next 是 D9 `varga_style`;新值 D9 记 `yearless_deferred`、next 不再是性格卡。原因:BUG-651 带年月池空即交付。 +- `open capability with empty dated probe pool delivers instead of collecting`:原值 `sessionOutcome=collect_evidence`、公开 `can_adopt=false`;新值 `adopt_representative`、可出区间卡。原因:训练门开且无带年月探针不得再被剩余采集挡住。 + +Skill 10.0.23 未改。 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index d854ba6d..1e8929f0 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -19,6 +19,19 @@ - 点「先这样,先看当前范围」后必须出现候选卡或当前范围,不得只剩「没有拿到下一个问题」 - 停止后卡片数字必须等于答题后的支持度(不是引擎刚算出来的裸相对支持度),范围不得比答题后更宽,点采用必须成功 +## 0b. 六道选择题答完必须出卡 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。 + +开场后依次说两件学业(2016-09 / 2020-06),对「还有吗」再补事业两件(2020-04 入职实习、2020-10 离职)。训练门开后连续答完六道带年月选择题。 + +期望: + +- 第六题答完必须看到三列区间卡,正文有一句还可以再想起什么来再收一截 +- 不得出现「没有拿到下一个问题」 +- 不得先出 D9/D10 性格对照卡挡住结果 +- 时间线有卡时写「选择题已问完,下面是当前范围」,不得写「才会变」 + ## 1. 范围:家人说两点到四点 资料:家人记得大概时间,钟点 15:00,范围「前后一小时」。地点任意公开城市。 diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index f74dccd6..f9266b10 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -262,12 +262,21 @@ function deliveryCapability(input: { }; } +/** Dated reverse-inference only. Yearless D9/D10 / nakshatra never occupy this slot (BUG-651). */ +export function isDatedDiscriminatorProbe( + probe: CandidateDiscriminatorProbe | null | undefined, +): probe is CandidateDiscriminatorProbe { + if (!probe) return false; + if (probe.choiceKind === "varga_style") return false; + return (probe.year ?? 0) > 0; +} + export function decideRectification(input: DecideRectificationInput): RectificationDecision { if (input.caseStage === "block_scan") { return decideBlockScan(input); } const separation = evaluateCandidateSeparation(input.candidateScores); - const probe = input.discriminatorProbe ?? null; + const probe = isDatedDiscriminatorProbe(input.discriminatorProbe) ? input.discriminatorProbe : null; const holdout = input.holdoutValidation ?? "unavailable"; const userStopped = input.userStopped === true; const confirmationAllowed = input.confirmationAllowed === true; @@ -315,7 +324,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat && separation.ranked.length > 0 && !probe && engineOffers - && input.datedMethodCollectOpen !== true ) { return offerRangeWithoutAdopt(separation, holdout, range, capability); } @@ -334,20 +342,8 @@ export function decideRectification(input: DecideRectificationInput): Rectificat if (holdout === "not_started" && !capability.canAdopt) { return holdoutValidation(separation, range, capability); } - if (input.datedMethodCollectOpen === true) { - return collect(separation, holdout, range, null, capability, stopReason); - } - if (input.nakshatraBoundaryProbe) { - return discriminateOrExhaust( - input, - separation, - holdout, - range, - input.nakshatraBoundaryProbe, - capability, - stopReason, - ); - } + // coverageBlocks already collected when the training gate is closed. + // An open leftover collect must not block S3 delivery (BUG-651). if (stopClass?.kind === "exhausted") { return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); } diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index eab4817d..2609b5c8 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -102,6 +102,7 @@ const EXHAUSTION_DELIVERY_ACTIONS = new Set([ "offer_provisional_range", "complete_with_range", "ask_fact_collection", + "ready_to_adopt", ]); export function isExhaustedGateState(input: { @@ -231,10 +232,11 @@ function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null { function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean { if (!followup) return false; if (followup.intent === "distinguish_candidates" && !followup.choice_frame) return false; + if (followup.choice_kind === "varga_style") return false; + if (followup.source === "nakshatra_boundary") return false; return followup.source === "event_probe" || followup.source === "varga_observation" || followup.source === "precision_stage" - || followup.source === "nakshatra_boundary" || followup.source === "reverse_verify" || followup.intent === "distinguish_candidates" || followup.intent === "reverse_verify"; @@ -300,12 +302,18 @@ function adoptHostNarration(input: { }): string { const inference = previousInferenceFromReceipt(input.receipt); const fit = refinementFromDecisionReceipt(input.receipt).event_fit_rate; - return deliveryAdoptNarration({ + const delivered = deliveryAdoptNarration({ credibleRange: input.decision.credibleRange, representativeTime: input.decision.representativeTime, eventCount: datedEventCount(inference), fitPercent: fit?.percent ?? null, }); + const hint = moreCollectHint( + input.dossier.evidence, + input.dossier.conversationSummary.declinedSkippedTopics, + ); + if (!hint || delivered.includes(hint)) return delivered; + return `${delivered} ${hint}`.replace(/\s+/g, " ").trim(); } function dossierWithCurrentInference( @@ -961,8 +969,27 @@ export async function persistNextInterviewAfterChoice(input: { askedTurnId: input.askedTurnId ?? null, }); } + const spoken = (spokenFollowupForUser(followup) ?? "").trim(); + if (!spoken) { + if (input.dossier.case.acceptedTime) { + return { + hostNarration: postAdoptDoneNarration(liveDossier, decision), + choiceReady: false, + followup: null, + }; + } + return persistExhaustionCollect({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier: liveDossier, + decision, + decisionReceipt: latest.decisionReceipt, + askedTurnId: input.askedTurnId ?? null, + }); + } return { - hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback, + hostNarration: spoken, choiceReady: false, followup, }; @@ -1359,11 +1386,7 @@ export async function persistNextInterviewIfIdle(input: { askedTurnId: input.askedTurnId ?? null, })); } - if ( - !followup - && decision.nextAction !== "ask_candidate_discriminator" - && decision.nextAction !== "ask_holdout_validation" - ) { + if (!followup) { if (dossier.case.acceptedTime) { return finishIdle({ persisted: false, 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 c58ac178..2c9184c0 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,26 @@ export function followupAsksRenderableDiscriminator( return followup?.intent === "distinguish_candidates" && Boolean(followup.choice_frame); } +export function isDatedDiscriminatorFollowup( + followup: { + intent?: string; + choice_frame?: unknown; + choice_kind?: string; + source?: string; + probe_year?: number; + } | null | undefined, +): boolean { + if (!followupAsksRenderableDiscriminator(followup)) return false; + if (followup?.choice_kind === "varga_style") return false; + if (followup?.source === "nakshatra_boundary") return false; + if ((followup?.probe_year ?? 0) > 0) return true; + const frame = followup?.choice_frame; + const period = frame && typeof frame === "object" && !Array.isArray(frame) + ? String((frame as { period?: unknown }).period ?? "") + : ""; + return followup?.source === "event_probe" && /(?:19|20)\d{2}/.test(period); +} + export type DecideFromDossierOptions = Readonly<{ currentEvidenceFingerprint?: string | null; birthDate?: string | null; @@ -487,7 +507,7 @@ function discriminatorProbeIfFollowupCanAsk(input: { candidatesSeparated: dossierCandidatesSeparated(input.dossier), }); const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes); - if (!followupAsksRenderableDiscriminator(plan.next_followup)) { + if (!isDatedDiscriminatorFollowup(plan.next_followup)) { return { probe: null, dropped }; } const key = plan.next_followup?.semantic_key; @@ -714,7 +734,7 @@ export function decideFromDossier( snapshotCurrent, candidateScores: candidateScoresFromDossier(dossier.latestResult), discriminatorProbe: gated.probe, - nakshatraBoundaryProbe: nakshatra.probe, + nakshatraBoundaryProbe: null, holdoutValidation, accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, @@ -848,7 +868,7 @@ export function decideAfterInferenceChange(input: { .filter((item) => item.status !== "eliminated") .map((item) => ({ time: item.time, score: item.posterior_score })), discriminatorProbe: gated.probe, - nakshatraBoundaryProbe: nakshatra.probe, + nakshatraBoundaryProbe: null, holdoutValidation, inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index e9c35290..664b0fff 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -44,8 +44,9 @@ * Yearless varga splits do not borrow a ledger year. Pick the next * dated discriminator, or collect a dated event in that domain. * Remaining dated dasha distinguish probes are asked first. Yearless - * D9/D10 style and nakshatra_boundary wait until that pool is empty and - * at least two active candidates are still unseparated (BUG-629). + * D9/D10 style and nakshatra_boundary stay out of the dated pool and + * never occupy ask_candidate_discriminator (BUG-629, BUG-651). When + * the dated pool is empty after the training gate, deliver the range. * If holdout is already reserved but training is still short, * keep collecting a dated event instead of discriminating. * Once blocking methods are covered, move into candidate discrimination. @@ -1102,7 +1103,7 @@ function isYearlessPersonalityRow(row: RankedDiscriminator): boolean { return discriminatorChoiceKind(row) === "varga_style"; } -function yearlessPersonalityCanAsk(input: { +export function yearlessPersonalityCanAsk(input: { datedCount: number; candidatesSeparated: boolean; topCandidateTimes?: readonly string[]; @@ -2340,29 +2341,21 @@ export function buildMethodFollowupPlan(input: { } } } - const personalityAllowed = yearlessPersonalityCanAsk({ - datedCount: rankedDiscriminators.length, - candidatesSeparated, - topCandidateTimes: input.topCandidateTimes, - }); - if (dashaCovered && personalityAllowed && !next) { - const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated; - for (const ranked of personalityDiscriminators) { - if (!allowLowGainDiscriminator && ranked.score < 0.08) continue; - const candidate = followupFromRanked(ranked); - if (candidate.choice_frame) { - next = candidate; - break; - } - } - } else { - for (const ranked of personalityDiscriminators) { - extraDropped.push(droppedFromProbe( - ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style", - ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score, - "yearless_deferred", - )); - } + // BUG-651: personality / nakshatra stay optional after range delivery. + // They never occupy the discriminator slot, even when the dated pool is empty. + for (const ranked of personalityDiscriminators) { + extraDropped.push(droppedFromProbe( + ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style", + ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score, + "yearless_deferred", + )); + } + if (input.nakshatraProbe) { + extraDropped.push(droppedFromProbe( + input.nakshatraProbe.semantic_key, + input.nakshatraProbe.information_gain, + "yearless_deferred", + )); } if (!next && !dashaCovered) { next = makeFollowup({ @@ -2389,51 +2382,7 @@ export function buildMethodFollowupPlan(input: { } // S1 ranks invite / anchors / generics. Do not convert leftover yearless // contrast back into a domain wheel after the training gate (BUG-648). - if ( - !next - && personalityAllowed - && input.holdoutValidation !== "not_started" - && !datedMethodCollectOpen(methods, input.evidence, [ - ...(input.declinedTopics ?? []), - ...(input.closedCollectFocuses ?? []), - ]) - && input.nakshatraProbe - ) { - const probe = input.nakshatraProbe; - const nakshatraFollowup = makeFollowup({ - method_id: "nakshatra_boundary", - intent: "distinguish_candidates", - ask_theme: "nakshatra_trait", - domain: probe.domain, - kind_hint: null, - user_prompt_hint: probe.question, - source: "nakshatra_boundary", - information_gain: probe.information_gain, - semantic_key: probe.semantic_key, - candidate_split_hash: probe.candidate_split_hash, - probe_year: probe.year, - choice_kind: probe.choice_kind ?? "varga_style", - candidate_ids: probe.candidate_ids, - expected_outcomes: probe.expected_outcomes, - style_options: probe.style_options, - probe_id: probe.id, - }, true, true); - if (nakshatraFollowup.choice_frame) { - next = nakshatraFollowup; - } else { - extraDropped.push({ - semantic_key: probe.semantic_key, - information_gain: probe.information_gain, - reason: "not_renderable", - }); - } - } else if (input.nakshatraProbe && !personalityAllowed) { - extraDropped.push(droppedFromProbe( - input.nakshatraProbe.semantic_key, - input.nakshatraProbe.information_gain, - "yearless_deferred", - )); - } + // BUG-651: nakshatra is already deferred above and must not take this slot. const sameDomainYearlessCard = (domain: string): MethodFollowup | null => { const ranked = yearlessDiscriminators.find((row) => ( (row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain @@ -2542,8 +2491,11 @@ export function buildMethodFollowupPlan(input: { : null; if (!next) { const precisionCard = takeRenderableDistinguish(precisionStageFollowup()); + const datedPoolEmpty = rankedDiscriminators.length === 0; if (collectingByPool) { next = null; + } else if (datedPoolEmpty && meetsAcceptanceEventQuality(input.evidence)) { + next = null; } else if ((renderableYearless = firstRenderableYearlessFollowup())) { next = renderableYearless; } else if ( diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 4708d8fc..f48a9220 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -431,6 +431,27 @@ export async function persistServerOwnedFocus(input: { decisionReceipt: Readonly> | null | undefined; followup: MethodFollowup | null; askedTurnId?: string | null; +}): Promise { + const result = await persistServerOwnedFocusCore(input); + if (result.status !== "created" && result.status !== "already_open") { + console.warn(JSON.stringify({ + event: "rectification_discriminator_persist_skipped", + case_id: input.caseId, + status: result.status, + semantic_key: input.followup?.semantic_key ?? null, + })); + } + return result; +} + +async function persistServerOwnedFocusCore(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + activeFocus: ConversationFocus | null; + decisionReceipt: Readonly> | null | undefined; + followup: MethodFollowup | null; + askedTurnId?: string | null; }): Promise { const followup = input.followup; const frame = followup?.choice_frame ?? null; diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 47b15274..2b47d890 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -360,36 +360,32 @@ export function interviewCollectWaiting(input: Readonly<{ || input.stopReason === "insufficient_events"; } -function probeHasYear(value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const year = (value as { year?: unknown }).year; - return typeof year === "number" && Number.isFinite(year) && year > 0; -} - -function listHasDatedProbe(value: unknown): boolean { - if (!Array.isArray(value)) return false; - return value.some(probeHasYear); -} - export function contrastProbesFromReceipt(receipt: Readonly> | null | undefined): unknown { const packet = receipt?.candidate_contrast_packet; if (!packet || typeof packet !== "object" || Array.isArray(packet)) return null; return (packet as { probes?: unknown }).probes ?? null; } -/** Timeline range line. Never says 收窄. */ +/** Timeline range line. Never says 收窄. Ban the exhausted-deadend phrase. */ export function rectificationReadonlyRangeCopy(input: Readonly<{ range: readonly [string, string]; sessionOutcome?: string | null; discriminatingEventProbes?: unknown; contrastProbes?: unknown; }>): string { - const datedOpen = listHasDatedProbe(input.discriminatingEventProbes) - || listHasDatedProbe(input.contrastProbes); - const choicesExhausted = input.sessionOutcome === "discriminate_candidates" && !datedOpen; - const suffix = choicesExhausted - ? "选择题已问完,再补带年月的经历才会变" - : "还在核对"; + const outcome = input.sessionOutcome ?? ""; + const delivery = outcome === "adopt_representative" + || outcome === "provisional_range" + || outcome === "provisional_range_user_stopped" + || outcome === "completed_with_range" + || outcome === "validated_range" + || outcome === "awaiting_confirmation" + || outcome === "exact_minute_confirmed"; + const suffix = delivery + ? "选择题已问完,下面是当前范围" + : outcome === "collect_evidence" + ? "再说一件带年月的事就能继续" + : "还在核对"; return `目前范围 ${input.range[0]}–${input.range[1]},${suffix}`; } diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index e8835c9c..966d8c4c 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -198,6 +198,11 @@ test("GENERIC_COLLECT_QUESTION is only allowed on opening collect:other focus", const banned = "也可以再" + "说一件"; assert.equal(copy.includes(banned), false); assert.equal(listUserVisibleCopy().some((item) => item.includes(banned)), false); + const surface = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8"); + const bannedExhausted = "才会" + "变"; + assert.equal(surface.includes(bannedExhausted), false); + assert.equal(copy.includes(bannedExhausted), false); + assert.equal(listUserVisibleCopy().some((item) => item.includes(bannedExhausted)), false); }); test("settled assistant body with a focus has no question-mark sentences", () => { diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 4907044f..dc0425e0 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -589,7 +589,7 @@ test("MethodFollowup unions include holdout validation kinds used by next_follow assert.match(askTheme, /"holdout"/); }); -test("collect_evidence with open capability still publishes can_adopt=false", () => { +test("open capability with empty dated probe pool delivers instead of collecting", () => { const collecting = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, @@ -601,12 +601,13 @@ test("collect_evidence with open capability still publishes can_adopt=false", () candidateScores: TIED, confirmationAllowed: false, }); - assert.equal(collecting.sessionOutcome, "collect_evidence"); + // 原值: sessionOutcome=collect_evidence,公开 can_adopt=false + // 新值: 训练门开、无带年月探针 → adopt_representative / 可出区间卡 + // 原因: BUG-651 带年月池空即交付,剩余采集不得挡住结果卡 + assert.equal(collecting.sessionOutcome, "adopt_representative"); + assert.equal(collecting.canOfferRange, true); assert.equal(collecting.canAdopt, true); - // 旧:collect() 把 capability.canAdopt 原样投影成 can_adopt。 - // 新:publicDecisionFields 按 ADOPT_OUTCOMES 收紧;collect 期间公开 can_adopt=false。 - // 保留语义:内部 canAdopt / selection_allowed 不变;holdout 已过的 validated_range 仍可公开采用。 - assert.equal(publicDecisionFields(collecting).can_adopt, false); + assert.equal(publicDecisionFields(collecting).can_adopt, true); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true); }); diff --git a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts new file mode 100644 index 00000000..a23a9d8e --- /dev/null +++ b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts @@ -0,0 +1,647 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; +import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; +import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; +import { + decideFromDossier, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + persistNextInterviewAfterChoice, + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts"; +import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { COLLECT_FLOW_BANNED_PHRASES } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; +import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; +import { + buildMethodFollowupPlan, + buildNextUserAction, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + persistServerOwnedFocus, + type PersistServerFocusStatus, +} from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + candidateSnapshotFixture, + computeFixture, + conversationSummaryFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const EXISTENCE_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 }, +]; +const VARGA_OPTIONS = [ + { label: "相处里更在意照顾对方的感受", answer_class: "yes" as const, sign: "巨蟹座" }, + { label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" as const, sign: "狮子座" }, +]; +const TIMES = ["04:48", "04:53", "04:54", "04:59", "05:06", "05:07"] as const; +const SCORES: Record = { + "04:48": 10, + "04:53": 15, + "04:54": 14, + "04:59": 13, + "05:06": 13, + "05:07": 12, +}; + +const educationStart = { + id: "e-edu-start", + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + eventKind: "education_start", + summary: "2016年9月上大学", +}; +const educationEnd = { + id: "e-edu-end", + status: "confirmed" as const, + domain: "education", + datePrecision: "month" as const, + occurredFrom: "2020-06-01", + occurredTo: "2020-06-30", + eventKind: "education_completion", + summary: "2020年6月毕业", +}; +const careerIntern = { + id: "e-career-intern", + status: "confirmed" as const, + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + summary: "2020年4月入职实习", +}; +const careerLeave = { + id: "e-career-leave", + status: "confirmed" as const, + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + summary: "2020年10月离职", +}; +const EVIDENCE = [educationStart, educationEnd, careerIntern, careerLeave]; + +function uuidAt(index: number) { + return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`; +} + +function existenceProbe(input: { + key: string; + domain: string; + year: number; + month?: number; + question: string; + source?: string; + choiceKind?: ConflictProbe["choice_kind"]; +}): ConflictProbe { + return { + id: `probe:${input.key}`, + semantic_key: input.key, + candidate_split_hash: input.key, + domain: input.domain, + year: input.year, + question: input.question, + candidate_ids: [...TIMES], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] }, + { answer_class: "weak_yes", supports: [], conflicts: [] }, + { answer_class: "no", supports: ["05:06"], conflicts: ["04:54"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: input.source ?? "dasha_boundary", + choice_kind: input.choiceKind ?? "existence", + style_options: EXISTENCE_OPTIONS, + }; +} + +const ASKED_PROBES = [ + existenceProbe({ + key: "career.2023.05.dasha_boundary", + domain: "career", + year: 2023, + month: 5, + question: "2023 年 5 月前后有没有入职或换工作", + }), + existenceProbe({ + key: "relationship.2023.05.dasha_boundary", + domain: "relationship", + year: 2023, + month: 5, + question: "2023 年 5 月前后感情有没有明显变化", + }), + existenceProbe({ + key: "relocation.2015.05.dasha_boundary", + domain: "relocation", + year: 2015, + month: 5, + question: "2015 年 5 月前后有没有搬家", + }), + existenceProbe({ + key: "education.2016.quality", + domain: "education", + year: 2016, + question: "2016 年那次学业发挥怎么样", + source: "known_event_quality", + choiceKind: "event_quality", + }), + existenceProbe({ + key: "career.2024.04.dasha_boundary", + domain: "career", + year: 2024, + month: 4, + question: "2024 年 4 月前后有没有入职或换工作", + }), + existenceProbe({ + key: "relationship.2024.04.dasha_boundary", + domain: "relationship", + year: 2024, + month: 4, + question: "2024 年 4 月前后感情有没有明显变化", + }), +]; + +const SIXTH = ASKED_PROBES[5]!; + +const LEFTOVER_SAME_YEAR = existenceProbe({ + key: "career.2023.dasha_activation", + domain: "career", + year: 2023, + question: "2023 年前后有没有职责加重", + source: "dasha_activation", +}); + +const D10_STYLE: ConflictProbe = { + id: "probe:varga.d10.狮子座/处女座", + semantic_key: "varga.d10.狮子座/处女座", + candidate_split_hash: "04:48-05:07:04:49,04:53:varga.d10.狮子座/处女座", + domain: "career", + year: 0, + question: "平时做事,你更接近下面哪一种?", + candidate_ids: [...TIMES], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] }, + { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.9, + source: "varga_contrast", + choice_kind: "varga_style", + style_options: VARGA_OPTIONS, +}; + +const NAKSHATRA: ConflictProbe = { + id: "probe:nakshatra.boundary", + semantic_key: "nakshatra.boundary.a/b", + candidate_split_hash: "nakshatra.boundary.a/b", + domain: "other", + year: 0, + question: "两组月宿性格里更接近哪一种?", + candidate_ids: ["04:54", "05:06"], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] }, + { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.01, + source: "nakshatra_boundary", + choice_kind: "varga_style", + style_options: VARGA_OPTIONS, +}; + +function liveState(answeredCount: number) { + const answered = ASKED_PROBES.slice(0, answeredCount); + const leftoverYearless = [1, 2, 3, 4, 5].map((index) => existenceProbe({ + key: `yearless.existence.${index}`, + domain: "career", + year: 0, + question: "有没有过一次说不清年份的工作变化", + source: "varga_contrast", + })); + const probes = [ + ...answered, + LEFTOVER_SAME_YEAR, + ...leftoverYearless, + D10_STYLE, + NAKSHATRA, + ]; + const candidates = TIMES.map((time, index) => ({ + id: time, + time, + cluster_range: [time, time] as const, + prior_score: SCORES[time] ?? 0, + posterior_score: SCORES[time] ?? 0, + probability: (SCORES[time] ?? 0) / 76, + status: "active" as const, + rank: index + 1, + strong_conflict_count: 0, + })); + const raw = { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: candidateSetId("04:48", "05:07", TIMES), + revision: answeredCount, + phase: "discrimination" as const, + result_status: "discriminating" as const, + range_start: "04:48", + range_end: "05:07", + candidates, + events: [ + { id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const }, + { id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: careerIntern.id, domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: careerLeave.id, domain: "career", year: 2020, precision: "month" as const, usage: "holdout" as const }, + ], + probes, + answered_probes: answered.map((probe) => ({ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: probe.semantic_key.includes("2024.04") && probe.domain === "career" ? "yes" as const : "no" as const, + classified_from: "choice" as const, + })), + rounds: [], + last_inference_round: null, + entropy: 1.4, + representative_time: "04:53", + credible_range: ["04:48", "05:07"] as const, + holdout_passed: null, + }; + const loaded = asInferenceState(raw); + assert.ok(loaded); + return loaded; +} + +function eventProbeRow(probe: ConflictProbe) { + return { + year: probe.year, + year_label: probe.year > 0 ? `${probe.year} 年前后` : "", + domain: probe.domain, + event_family: probe.domain, + source: probe.source, + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: probe.question, + role: "distinguish", + information_gain: probe.information_gain, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + candidate_ids: probe.candidate_ids, + expected_outcomes: probe.expected_outcomes, + choice_kind: probe.choice_kind, + style_options: probe.style_options, + }; +} + +function accidentDossier(answeredCount: number, extra: { + activeFocus?: ReturnType | null; +} = {}): DecisionDossier { + const state = liveState(answeredCount); + const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); + return { + evidence: EVIDENCE, + conversationSummary: { + activeFocus: extra.activeFocus + ? { + id: extra.activeFocus.id, + intent: extra.activeFocus.intent, + targetDomain: extra.activeFocus.target_domain, + targetKind: extra.activeFocus.target_kind, + expectedAnswerSchema: extra.activeFocus.expected_answer_schema, + } + : null, + declinedSkippedTopics: [{ + target_domain: "other", + status: "declined", + intent: "collect_method_evidence", + questionId: "collect:invite:more", + target_kind: "invite_more", + }], + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + selectionAllowed: true, + confirmationAllowed: false, + evidenceLedgerFingerprint: fingerprint, + candidates: TIMES.map((time, index) => ({ + candidateId: uuidAt(index), + time, + rank: index + 1, + relativeSupport: SCORES[time] ?? 0, + })), + representativeTime: "04:53", + decisionReceipt: { + accept_allowed: true, + acceptance_allowed: true, + propose_allowed: true, + selection_allowed: true, + confirmation_allowed: false, + acceptance_reasons: [], + inference_state: state, + discriminating_event_probes: [ + ...ASKED_PROBES.map(eventProbeRow), + eventProbeRow(LEFTOVER_SAME_YEAR), + eventProbeRow(D10_STYLE), + ], + oos_blind_prompts: [], + }, + }, + case: { acceptedTime: null, status: "collecting_evidence" }, + }; +} + +function rpcDossier(decision: DecisionDossier, extra: { + activeFocus?: ReturnType | null; +} = {}) { + const evidence = decision.evidence.map((item) => ({ + id: item.id ?? "e-unknown", + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? item.domain, + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: item.summary ?? item.domain, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-11T00:00:00.000Z", + })); + return dossierFixture({ + evidence, + evidenceCount: evidence.length, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "04:53", + evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never), + candidates: decision.latestResult?.candidates?.map((item, index) => ({ + candidate_id: item.candidateId ?? uuidAt(index), + time: item.time, + rank: item.rank ?? index + 1, + relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)), + tied_minute_count: 1, + })) ?? [], + decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: extra.activeFocus ?? null, + declinedSkippedTopics: [...decision.conversationSummary.declinedSkippedTopics], + }), + }); +} + +function idleHandlers(decision: DecisionDossier, extra: { + activeFocus?: ReturnType | null; + throwOnFocus?: boolean; +} = {}) { + return fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra), + get_agentic_rectification_case_compute: () => computeFixture(), + 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: SIXTH.id, + revision: Number(args.p_expected_revision) + 1, + source_quote: args.p_source_quote, + derived_context: args.p_derived_context, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + set_agentic_rectification_conversation_focus: extra.throwOnFocus + ? () => { + throw new Error("persist skipped"); + } + : (_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, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-11T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }, + idempotent: false, + }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + get_agentic_rectification_turn_receipt: () => null, + }); +} + +function warnLines(run: () => Promise | unknown) { + const lines: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + lines.push(args.map((item) => String(item)).join(" ")); + original.apply(console, args); + }; + return Promise.resolve(run()).finally(() => { + console.warn = original; + }).then((result) => ({ result, lines })); +} + +function followupPlan(dossier: DecisionDossier, sessionOutcome: string) { + return buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: sessionOutcome as never, + eventProbes: (dossier.latestResult?.decisionReceipt?.discriminating_event_probes ?? []) as never, + askedProbeKeys: ASKED_PROBES.flatMap((probe) => [probe.id, probe.semantic_key, probe.candidate_split_hash]), + candidatesSeparated: false, + topCandidateTimes: [...TIMES], + birthDate: "1997-08-08", + }); +} + +test("T0: sixth dated answer prints persist_status then must deliver a range card", async () => { + resetDeliveryTurnGuardForTests(); + const dossier = accidentDossier(6); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const plan = followupPlan(dossier, decision.sessionOutcome); + const accounting = idleHandlers(dossier); + const { result: persist, lines } = await warnLines(() => persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, + followup: plan.next_followup, + })); + const persistStatus = (persist as { status: PersistServerFocusStatus }).status; + console.warn(JSON.stringify({ + event: "t0_probe_pool_exhausted", + nextAction: decision.nextAction, + sessionOutcome: decision.sessionOutcome, + semantic_key: plan.next_followup?.semantic_key ?? null, + choice_kind: plan.next_followup?.choice_kind ?? null, + followup_intent: plan.next_followup?.intent ?? null, + persist_status: persistStatus, + probe_id: decision.probe?.probeId ?? null, + probe_year: decision.probe?.year ?? null, + probe_choiceKind: decision.probe?.choiceKind ?? null, + probe_semanticKey: decision.probe?.semanticKey ?? null, + })); + assert.ok( + decision.nextAction === "offer_provisional_range" + || decision.nextAction === "ready_to_adopt" + || decision.nextAction === "complete_with_range", + `T0 nextAction=${decision.nextAction} persist_status=${persistStatus} next=${plan.next_followup?.semantic_key}`, + ); + assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); + assert.notEqual(plan.next_followup?.source, "nakshatra_boundary"); + const publicAction = publicNextAction(decision); + assert.equal(publicAction.can_offer_range, true); + const nextUser = buildNextUserAction({ + scorableCount: dossier.evidence.length, + evidenceCount: dossier.evidence.length, + hasLatestResult: true, + selectionAllowed: publicAction.can_adopt, + sessionOutcome: decision.sessionOutcome, + nextFollowup: plan.next_followup, + workingTime: decision.representativeTime, + }); + assert.ok( + nextUser.id === "offer_provisional_range" || nextUser.id === "adopt_representative", + nextUser.id, + ); + const idleAccounting = idleHandlers(dossier); + const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({ + accounting: idleAccounting.client, + userId: USER_ID, + caseId: CASE_ID, + })); + const persisted = idle as Awaited>; + const host = persisted.hostNarration ?? ""; + assert.ok(host.trim(), "answer/idle transaction must leave a carrier"); + assert.match(host, /再收一截|再补|如果还记得/); + for (const phrase of COLLECT_FLOW_BANNED_PHRASES) { + if (phrase === "领域") continue; + assert.equal(host.includes(phrase), false, phrase); + } + const delivery = rangeDeliveryForSnapshot({ + decisionReceipt: dossier.latestResult?.decisionReceipt, + candidates: dossier.latestResult?.candidates, + representativeTime: decision.representativeTime, + credibleRange: decision.credibleRange, + }); + assert.ok((delivery.columns?.length ?? 0) >= 3, JSON.stringify(delivery.columns?.map((item) => item.time))); + assert.equal(rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: true, + questionLoadFailed: false, + collectWaiting: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: 0, + offerAwaitingReader: publicAction.can_offer_range, + }), "idle"); + assert.ok(lines.length >= 0); +}); + +test("T1: the sixth-answer persist transaction delivers a range carrier", async () => { + resetDeliveryTurnGuardForTests(); + const dossier = accidentDossier(6); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const accounting = idleHandlers(dossier); + const next = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: liveState(6), + nextAction: publicNextAction(decision), + decision, + birthDate: "1997-08-08", + }); + assert.ok( + decision.nextAction === "offer_provisional_range" + || decision.nextAction === "ready_to_adopt" + || decision.nextAction === "complete_with_range", + decision.nextAction, + ); + assert.equal(decision.canOfferRange, true); + assert.match(next.hostNarration, /再收一截|再补|如果还记得/); + assert.doesNotMatch(next.hostNarration, /做不了|才会变|没有拿到下一个问题/); + assert.equal(next.choiceReady, false); +}); + +test("T3: skipped discriminator persist still leaves a non-empty delivery carrier", async () => { + resetDeliveryTurnGuardForTests(); + const dossier = accidentDossier(6); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const accounting = idleHandlers(dossier, { throwOnFocus: true }); + const { result, lines } = await warnLines(() => persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: liveState(6), + nextAction: publicNextAction(decision), + decision, + birthDate: "1997-08-08", + })); + const next = result as Awaited>; + assert.ok((next.hostNarration ?? "").trim()); + assert.equal(decision.canOfferRange, true); + const skipped = lines.find((line) => line.includes("rectification_discriminator_persist_skipped")); + const skippedDirect = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, + followup: { + method_id: "d10_career", + intent: "distinguish_candidates", + ask_theme: "career_style", + domain: "career", + kind_hint: null, + user_prompt_hint: "ask", + must_not_label: false, + choice_frame: null, + source: "event_probe", + semantic_key: D10_STYLE.semantic_key, + choice_kind: "varga_style", + }, + }); + assert.equal(skippedDirect.status, "skipped"); + assert.ok(skipped || skippedDirect.status === "skipped"); +}); diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts index 7d03f035..d26a7def 100644 --- a/frontend/tests/rectification-surface-state.test.ts +++ b/frontend/tests/rectification-surface-state.test.ts @@ -120,29 +120,32 @@ test("question gap: a persisted focus question is shown instead of preparing", ( assert.equal(rectificationQuestionRetryActive("persisted_question"), false); }); -test("readonly range copy never says 收窄 and reports exhausted choices from the snapshot", () => { +test("readonly range copy never says 收窄 or 才会变", () => { assert.equal( rectificationReadonlyRangeCopy({ range: ["04:45", "05:15"], - sessionOutcome: "discriminate_candidates", + sessionOutcome: "adopt_representative", discriminatingEventProbes: [], contrastProbes: [], }), - "目前范围 04:45–05:15,选择题已问完,再补带年月的经历才会变", + "目前范围 04:45–05:15,选择题已问完,下面是当前范围", ); + // 原值: discriminate_candidates 且无带年月探针 →「选择题已问完,再补带年月的经历才会变」 + // 新值: 有交付卡才写「选择题已问完,下面是当前范围」;仍在区分时写「还在核对」 + // 原因: BUG-652 没有结果卡时不得写「做不了」口径 const stillOpen = rectificationReadonlyRangeCopy({ range: ["04:45", "05:15"], sessionOutcome: "discriminate_candidates", discriminatingEventProbes: [{ year: 2016 }], }); assert.match(stillOpen, /还在核对/); - assert.doesNotMatch(stillOpen, /收窄/); + assert.doesNotMatch(stillOpen, /收窄|才会变/); const collecting = rectificationReadonlyRangeCopy({ range: ["04:45", "05:15"], sessionOutcome: "collect_evidence", }); - assert.match(collecting, /还在核对/); - assert.doesNotMatch(collecting, /选择题已问完|收窄/); + assert.match(collecting, /再说一件带年月的事就能继续/); + assert.doesNotMatch(collecting, /选择题已问完|收窄|才会变/); }); test("live-row labels follow the action that started the turn", () => { diff --git a/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts b/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts index 7967b0b4..0cff8616 100644 --- a/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts +++ b/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts @@ -260,14 +260,19 @@ test("an askable 2023.05 career probe keeps D9 deferred", () => { ); }); -test("dated distinguish empty and two unseparated candidates asks D9", () => { +test("dated distinguish empty and two unseparated candidates defers D9", () => { const plan = personalityPlan({ eventProbes: [] }); - assert.equal(plan.next_followup?.semantic_key, D9_STYLE.semanticKey); - assert.equal(plan.next_followup?.choice_kind, "varga_style"); - assert.ok(plan.next_followup?.choice_frame); + // 原值: next_followup 是 D9 varga_style,dropped 不含 yearless_deferred + // 新值: D9 记 yearless_deferred,next 不再是性格卡 + // 原因: BUG-651 带年月池空即交付,性格题不得挡在结果前面 + assert.notEqual(plan.next_followup?.semantic_key, D9_STYLE.semanticKey); + assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); assert.equal( - plan.dropped_probes.some((item) => item.reason === "yearless_deferred"), - false, + plan.dropped_probes.some((item) => ( + item.semantic_key === D9_STYLE.semanticKey && item.reason === "yearless_deferred" + )), + true, + JSON.stringify(plan.dropped_probes), ); });