diff --git a/CHANGELOG.md b/CHANGELOG.md index 67204ad0..36095f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-08-30 — 非收敛区间提议不再是无出口死胡同(Skill 10.0.13 不变) + +训练门已开、覆盖层仍挡住确认、可渲染区分题又问不出来时,助手会说「可以先按当前区间看盘,也可以再补一件记得时间的经历」,但时间卡、下一问、区间数值都不给。本轮把这句话改成与真实承载一致。 + +- 正文写出可信区间与代表分钟,并沿用既有「代表分钟不是已确认的唯一出生分钟」口径。 +- 可执行出口落一条未用过的 family / education / finance 口述采集题(`current_question` 非 null);不放开 `canAdopt` / confirmation gate,不出时间卡。 +- 带年份反推题不再因为该领域已有别的年份证据而被整域丢掉;信息量按当前活跃候选重算,对活跃集合零区分力的探针写入 `dropped_probes`(`no_split_among_active`)。 + ## 2026-08-30 — 口述采集题由服务器接在正文后(Skill 10.0.13 不变) 自由文本记下经历后,`collect_spoken` 题已经落库,但助手只说「记下了:…」,用户看不到下一问。点选路径本来就会把 `spokenFollowupForUser` 写进正文;自由文本路径把这句题干丢了,而且只在正文为空时补。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6ac3f154..c23d11eb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6894,3 +6894,19 @@ - 相关记录:BUG-404、BUG-432、BUG-440、BUG-441 - 复发自:BUG-441 - 修复版本:待发布 + +## BUG-450 | 非收敛区间提议无数值、无时间卡、无下一问 + +- 状态:resolved +- 首次发现:2026-08-30 +- 最近更新:2026-08-30 +- 影响面:`offerRangeWithoutAdopt`、`persistNextInterviewIfIdle`、`projectRectificationChoiceCard`、`buildCandidateContrastPacket`、出题层 `rankRenderableDiscriminators`、POST `/api/rectification/agent` 消息快路径 +- 用户现象:答完最后一问后助手说「可以先按当前区间看盘,也可以再补一件记得时间的经历」,界面既没有可采用的时间卡,也没有下一个问题,正文里连区间数值都没有,对话到此为止。 +- 触发条件:训练门已开、occupation 等阻塞覆盖未齐、`datedMethodCollectOpen` 为假、引擎已放行 accept/propose、`probe === null`、`canOfferRange=true` 且 `canAdopt` / `selectionAllowed` / `proposeAllowed` 全假。本例活跃候选 04:47/04:51/04:53/04:59,family 已 declined,账本已有 2020 career 与 2024 relationship。 +- 根因:三段叠加。(1) P0 消费端死胡同:`offerRangeWithoutAdopt` 把 `canOfferRange` 设真却把 `canAdopt`/`selectionAllowed` 保持假;`isNonConvergingRangeOffer` 让 `persistNextInterviewIfIdle` 早退不落下一问;`projectRectificationChoiceCard` 对 `provisional_range` 一律 null;前端时间卡要 `selectionAllowed`。同一句双出口常量又重复写在 `rectification-decision.ts`、`route.ts`、`answer-choice.ts`,正文许诺看盘或再补经历,两个出口都没有承载。(2) P1 `probe=null` 的真实输入:年份探针活在 `inference_state.probes`(对比包)而不是 `discriminating_event_probes`。`buildCandidateContrastPacket` 对非结构化探针在 `provided.has(domain)` 时整域丢弃,2020 career 证据因此杀掉全部 career 存在/大运题,包括 `career.2024.04.dasha_boundary`。这与出题层「Dasha existence 跳过账本已有的那年,不是整个领域」不一致。`remainingConflictProbes` 走年份邻近规则,所以把同一探针放进 `discriminating_event_probes` 的重建会问出来。出题层在 discriminate 模式下于是落到 occupation `method_coverage` 口述题,`followupAsksRenderableDiscriminator` 为假,决策把 `probe` 打成 null。occupation 阻塞 adopt 但不在 `datedMethodCollectOpen` 里,于是进入 `offerRangeWithoutAdopt`。该跳过没有写入 `dropped_probes`。已排除:`askedKeys` 把 `candidate_split_hash` 误判为已答(2023.05 与 2024.04 哈希不同);`activeFocus` 未关(`discriminatorProbeIfFollowupCanAsk` 不传入旧焦点)。(3) P2:即使按年保留,`career.2024.04` 切的是 05:00 之后 vs 之前,`career.2023.dasha_activation` 只拎 05:15;淘汰后活跃四分钟上信息量为 0。出题层原先沿用建题时对全窗口的 IG,零区分力探针既不问也不 drop。 +- 修复:P0 选口述采集出口,不放开确认门与 `canAdopt`(occupation 仍挡住 adopt,也不新造无选项容器)。`offerRangeWithoutAdopt` 正文改为 `nonConvergingRangeNarration`(区间数值 + 代表分钟 + 既有免责口径);同一轮 `exhaustionSpokenCollectFollowup` 按 family → education → finance 落一条未用过的 `oos_blind` 口述题并持久化,使 `current_question` 非 null。三处相同字符串合并为该导出函数。P1:对比包与 `rankRenderableDiscriminators` 对带年份非结构化探针改为按年跳过,不再整域丢弃。P2:按当前活跃候选重算信息量;零区分力写入 `dropped_probes.reason=no_split_among_active`。未放宽 `MIN_BOUNDARY_DAYS`、distinguish 契约、confirmation gate,未回退 b43808b0 无年份收窄。 +- 验证:`frontend/tests/rectification-range-offer-deadend.test.ts`。修复前:`persistNextInterviewIfIdle` 在 `isNonConvergingRangeOffer` 返回 `{persisted:false, hostNarration:null}`;正文是无数值的双出口常量;`projectRectificationChoiceCard` 为 null;`canAdopt=false`。修复后:正文含 `04:47–04:53` 与代表分钟 `04:51`,并落 education `collect_spoken`。P1 最小夹具:探针只在 `inference_state.probes`、career 领域已有 2020 证据、occupation 未覆盖;对活跃四分钟仍有切分的 `career.2022.dasha_boundary` 走 `ask_candidate_discriminator`,不得进区间提议。P2:`career.2024.04.dasha_boundary` 与 `career.2023.dasha_activation` 进入 `dropped_probes` 且 reason 为 `no_split_among_active`。`userStopped` 仍走 `provisional_range_user_stopped` 且 `canAdopt=true`。`rectification-collect-stall` / `rectification-spoken-collect` / BUG-442 occupation / b43808b0 无年份收窄保持绿。`assert.doesNotMatch(/answerText\.(?:includes|match|search)\(/)` 仍绿。 +- 防复发:助手正文承诺的每个出口,同一轮必须有对应的可点承载;区间提议必须带区间数值与代表分钟口径;探针信息量必须对当前活跃候选计算,失去区分力的探针要显式 drop 而不是静默留着。不得为了出看盘按钮而放宽 `canAdopt` / confirmation gate。 +- 相关记录:BUG-432、BUG-440、BUG-441、BUG-442、BUG-449 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 6a981837..22fd45eb 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -39,13 +39,11 @@ import { } from "@/lib/rectification-agentic/v9/turn-intent-classifier"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; -import { isNonConvergingRangeOffer } from "@/lib/rectification-agentic/core/rectification-decision"; +import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision"; export const runtime = "nodejs"; export const maxDuration = 240; -export const DISCRIMINATOR_EXHAUSTED_NARRATION = "可以先按当前区间看盘,也可以再补一件记得时间的经历"; - function completedMessageResponse(text: string, requestId: string, caseId: string) { const body = [ JSON.stringify({ type: "answer.delta", text }), @@ -436,12 +434,14 @@ export async function POST(request: Request) { } const decision = decideFromDossier(dossier, { birthDate }); if (isNonConvergingRangeOffer(decision)) { + const idle = await persistNextInterviewIfIdle({ accounting, userId, caseId }); + const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision); await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, - assistantMessage: DISCRIMINATOR_EXHAUSTED_NARRATION, + assistantMessage, }); - return completedMessageResponse(DISCRIMINATOR_EXHAUSTED_NARRATION, requestId, caseId); + return completedMessageResponse(assistantMessage, requestId, caseId); } if (decision.nextAction === "ask_candidate_discriminator") { const catalog = rectificationFollowupCatalog( @@ -509,12 +509,14 @@ export async function POST(request: Request) { return completedMessageResponse(narration, requestId, caseId); } if (!plan.next_followup) { + const idle = await persistNextInterviewIfIdle({ accounting, userId, caseId }); + const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision); await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, - assistantMessage: DISCRIMINATOR_EXHAUSTED_NARRATION, + assistantMessage, }); - return completedMessageResponse(DISCRIMINATOR_EXHAUSTED_NARRATION, requestId, caseId); + return completedMessageResponse(assistantMessage, requestId, caseId); } } } diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index b2257e36..9c56c7de 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -13,6 +13,7 @@ import { } from "../v9/varga-type-tables.ts"; import { completeStyleOptions, + informationGainAmongActive, isRenderableProbe, type DroppedProbe, rankDiscriminatorScore, @@ -339,7 +340,7 @@ export function buildCandidateContrastPacket(input: { || asked.has(built.candidateSplitHash) || asked.has(built.probeId) || (!isStructured && eventKey && asked.has(eventKey)) - || (!isStructured && built.domain && provided.has(built.domain)) + || (!isStructured && !(built.year && built.year > 0) && built.domain && provided.has(built.domain)) ) { return []; } @@ -447,6 +448,18 @@ export function inspectDiscriminatorProbes( }); return []; } + const top = options?.topCandidateTimes ?? []; + const activeGain = top.length >= 2 + ? informationGainAmongActive(completed.probe.expectedOutcomes, top) + : { splits: true, informationGain: completed.probe.informationGain }; + if (!activeGain.splits) { + dropped.push({ + semantic_key: completed.probe.semanticKey, + information_gain: completed.probe.informationGain, + reason: "no_split_among_active", + }); + return []; + } const layer = vargaLayerFromSemanticKey(completed.probe.semanticKey); const askedAlready = asked.has(completed.probe.semanticKey) || asked.has(completed.probe.candidateSplitHash) @@ -455,7 +468,7 @@ export function inspectDiscriminatorProbes( return [{ probe: completed.probe, score: rankDiscriminatorScore({ - informationGain: completed.probe.informationGain, + informationGain: activeGain.informationGain, asked: askedAlready, candidateIds: ids, topCandidateTimes: options?.topCandidateTimes, diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 277db804..753f5cdb 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -250,7 +250,28 @@ export function isNonConvergingRangeOffer(decision: Pick< && decision.nextAction === "offer_provisional_range"; } -export const NON_CONVERGING_RANGE_NARRATION = "可以先按当前区间看盘,也可以再补一件记得时间的经历"; +export const REPRESENTATIVE_MINUTE_DISCLAIMER = "代表分钟只是代表性候选,不是已确认的唯一出生分钟。"; + +export function nonConvergingRangeNarration(input: { + credibleRange?: readonly [string, string] | null; + representativeTime?: string | null; +} = {}): string { + const range = input.credibleRange; + const representative = input.representativeTime?.trim() || null; + const rangeText = range?.[0] && range[1] + ? range[0] === range[1] ? range[0] : `${range[0]}–${range[1]}` + : null; + if (rangeText && representative) { + return `当前可信区间是 ${rangeText},代表分钟 ${representative}。${REPRESENTATIVE_MINUTE_DISCLAIMER}`; + } + if (rangeText) { + return `当前可信区间是 ${rangeText}。${REPRESENTATIVE_MINUTE_DISCLAIMER}`; + } + if (representative) { + return `当前代表分钟 ${representative}。${REPRESENTATIVE_MINUTE_DISCLAIMER}`; + } + return `当前几个候选还分不开。${REPRESENTATIVE_MINUTE_DISCLAIMER}`; +} function discriminate( separation: CandidateSeparation, diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 1dab44e3..3a75d27b 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -8,7 +8,7 @@ import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { nextProbe } from "../core/build-state"; -import { isNonConvergingRangeOffer, publicNextAction } from "../core/rectification-decision.ts"; +import { isNonConvergingRangeOffer, nonConvergingRangeNarration, publicNextAction } from "../core/rectification-decision.ts"; import { applyChoiceWithoutEvidence, previousInferenceFromReceipt, @@ -43,6 +43,7 @@ import type { ChoiceKey } from "./choice-card"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion } from "./server-focus"; import { buildMethodFollowupPlan, + exhaustionSpokenCollectFollowup, spokenCollectFallbackFollowup, spokenFollowupForUser, } from "./method-followup"; @@ -353,8 +354,29 @@ export async function persistNextInterviewAfterChoice(input: { return { hostNarration: null, choiceReady: false }; } if (!followup) { + if (isNonConvergingRangeOffer({ + canOfferRange: input.nextAction.can_offer_range, + canAdopt: input.nextAction.can_adopt, + canConfirmExactMinute: input.nextAction.can_confirm_exact_minute, + nextAction: input.nextAction.type, + })) { + return persistExhaustionCollect({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier: input.dossier, + decision: { + credibleRange: input.nextAction.credible_range, + representativeTime: input.nextAction.representative_time, + }, + decisionReceipt: latest.decisionReceipt, + }); + } return { - hostNarration: "可以先按当前区间看盘,也可以再补一件记得时间的经历", + hostNarration: nonConvergingRangeNarration({ + credibleRange: input.nextAction.credible_range, + representativeTime: input.nextAction.representative_time, + }), choiceReady: false, }; } @@ -472,7 +494,14 @@ export async function persistNextInterviewIfIdle(input: { } const decision = decideFromDossier(dossier, { birthDate }); if (isNonConvergingRangeOffer(decision)) { - return { persisted: false, choiceReady: false, hostNarration: null }; + return persistExhaustionCollect({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier, + decision, + decisionReceipt: dossier.latestResult?.decisionReceipt, + }); } const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ @@ -505,6 +534,45 @@ export async function persistNextInterviewIfIdle(input: { }; } +async function persistExhaustionCollect(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + dossier: { + evidence: Parameters[0]["evidence"]; + conversationSummary: { declinedSkippedTopics?: readonly Readonly>[] }; + latestResult?: { decisionReceipt?: Readonly> | null } | null; + }; + decision: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null }; + decisionReceipt?: Readonly> | null; +}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string }> { + const followup = exhaustionSpokenCollectFollowup({ + evidence: input.dossier.evidence, + declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + }); + const range = nonConvergingRangeNarration(input.decision); + const spoken = spokenFollowupForUser(followup); + const persistedFocus = followup + ? await persistFocusAfterChoice({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + decisionReceipt: input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt, + followup, + }) + : { status: "skipped" as const, focus: null, questionId: null, prompt: null }; + const persisted = Boolean(spoken) && ( + persistedFocus.status === "created" || persistedFocus.status === "already_open" + ); + return { + persisted, + choiceReady: false, + hostNarration: persisted && spoken + ? composeCollectSpokenAssistantText(range, spoken).composed + : range, + }; +} + export async function persistCollectSpokenAssistantIfNew(input: { accounting: AccountingClient; userId: string; 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 cb5b0971..e8e3962f 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -452,9 +452,11 @@ export function decideFromDossier( }); const askedKeys = askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence); const mentionedKeys = mentionedVargaKeysFromLedgerEvidence(dossier.evidence); + const topCandidateTimes = discriminatorCandidateTimes(dossier.latestResult); const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier), { askedKeys, mentionedKeys, + topCandidateTimes, }); const gated = discriminatorProbeIfFollowupCanAsk({ dossier, @@ -506,9 +508,13 @@ export function decideAfterInferenceChange(input: { const training = input.state.events.filter((item) => item.usage === "training"); const trainingDomains = new Set(training.map((item) => item.domain)); const contrastPacket = contrastPacketFromState(input.state); + const topCandidateTimes = input.state.candidates + .filter((item) => item.status !== "eliminated") + .map((item) => item.time); const inspected = inspectDiscriminatorProbes(contrastPacket, { mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.dossier.evidence), askedKeys: input.state.answered_probes.map((item) => item.semantic_key), + topCandidateTimes, }); const gated = discriminatorProbeIfFollowupCanAsk({ dossier: input.dossier, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 9243dad9..a84e28cc 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -30,6 +30,11 @@ * Known-event quality probes (exam went badly for a year already * in the ledger) are not reverse-inference cards. Dasha existence * probes skip a year already in the ledger, not the whole domain. + * Contrast-packet ranking must use the same year rule: a dated + * unstructured probe stays eligible when the domain already has a + * different year. Information gain is recomputed on the current + * active candidates; a probe that no longer splits that set is + * dropped as no_split_among_active. * Scoring A/B/C/D reverse-inference needs the engine year/month. * Yearless varga splits do not borrow a ledger year. Pick the next * dated discriminator, or collect a dated event in that domain. @@ -82,6 +87,7 @@ import { import { candidateIdsFromProbe, isValidDistinguishProbe } from "../core/distinguish-contract.ts"; import { completeStyleOptions, + informationGainAmongActive, isRenderableProbe, rankDiscriminatorScore, EXISTENCE_STYLE_OPTIONS, @@ -253,7 +259,7 @@ function birthYearFromDate(birthDate: string | null | undefined): number | null } function probeBelowAdultFloor( - probe: Pick, + probe: { domain: string; year: number }, birthDate?: string | null, ): boolean { const birthYear = birthYearFromDate(birthDate); @@ -577,6 +583,22 @@ function droppedFromProbe( }; } +function activeSplitForRanking( + outcomes: readonly Readonly<{ + answer_class?: string; + outcomeId?: string; + supports?: readonly string[]; + supportsCandidateIds?: readonly string[]; + }>[], + originalGain: number, + topCandidateTimes: readonly string[], +): { dropped: DroppedProbe | null; informationGain: number } | null { + if (topCandidateTimes.length < 2) return { dropped: null, informationGain: originalGain }; + const split = informationGainAmongActive(outcomes, topCandidateTimes); + if (split.splits) return { dropped: null, informationGain: split.informationGain }; + return null; +} + function renderableEventProbe( probe: DiscriminatingEventProbe, askedKeys: ReadonlySet, @@ -607,6 +629,14 @@ function renderableEventProbe( if (!renderable.ok && renderable.reason !== "yearless_ungrounded_contrast") { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason) }; } + const rankedGain = activeSplitForRanking( + probe.expected_outcomes ?? [], + probe.information_gain ?? 0, + topCandidateTimes, + ); + if (!rankedGain) { + return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "no_split_among_active") }; + } const layer = vargaLayerFromSemanticKey(key); const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)) @@ -617,7 +647,7 @@ function renderableEventProbe( eventProbe: probe, styleOptions: styleOptions.options, score: rankDiscriminatorScore({ - informationGain: probe.information_gain ?? 0, + informationGain: rankedGain.informationGain, asked, candidateIds, topCandidateTimes, @@ -666,6 +696,17 @@ function renderableContrastProbe( dropped: droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason), }; } + const rankedGain = activeSplitForRanking( + working.expectedOutcomes, + working.informationGain, + topCandidateTimes, + ); + if (!rankedGain) { + return { + row: null, + dropped: droppedFromProbe(working.semanticKey, working.informationGain, "no_split_among_active"), + }; + } const layer = vargaLayerFromSemanticKey(working.semanticKey); const asked = askedKeys.has(working.semanticKey) || askedKeys.has(working.candidateSplitHash) @@ -677,7 +718,7 @@ function renderableContrastProbe( contrastProbe: working, styleOptions, score: rankDiscriminatorScore({ - informationGain: working.informationGain, + informationGain: rankedGain.informationGain, asked, candidateIds, topCandidateTimes, @@ -802,6 +843,7 @@ function rankRenderableDiscriminators(input: { topCandidateTimes?: readonly string[]; providedDomains?: readonly string[]; evidence?: readonly MethodFollowupEvidence[]; + birthDate?: string | null; }): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } { const top = input.topCandidateTimes ?? []; const provided = new Set(input.providedDomains ?? []); @@ -824,7 +866,17 @@ function rankRenderableDiscriminators(input: { push(renderableEventProbe(probe, input.askedKeys, top, mentioned)); } for (const probe of input.contrastProbes) { - if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) continue; + if ( + probe.domain + && probeBelowAdultFloor({ domain: probe.domain, year: probe.year ?? 0 }, input.birthDate) + ) { + continue; + } + if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) { + const year = probe.year ?? 0; + if (year <= 0) continue; + if (input.evidence && probeYearAlreadyCovered(input.evidence, probe.domain, year)) continue; + } push(renderableContrastProbe(probe, input.askedKeys, top, mentioned)); } const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0)); @@ -884,6 +936,7 @@ const USER_COLLECT_QUESTION: Readonly> = { occupation: "你长期做什么工作?", education: "有没有记得住年份的升学、转学或考试?", relocation: "有没有记得住时间的搬家或长期住到外地?", + finance: "有没有记得住时间的收入变化、大笔支出或欠债?", }; export function spokenFollowupForUser(followup: MethodFollowup | null): string | null { @@ -897,6 +950,48 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string | return period ? `${period},${base}` : base; } +const EXHAUSTION_COLLECT_ORDER = ["family", "education", "finance"] as const; + +export function exhaustionSpokenCollectFollowup(input: { + evidence: readonly MethodFollowupEvidence[]; + declinedTopics?: readonly Readonly>[]; +}): MethodFollowup | null { + const declined = declinedDomains(input.declinedTopics ?? []); + for (const domain of EXHAUSTION_COLLECT_ORDER) { + if (declined.has(domain)) continue; + if (hasConfirmedDomain(input.evidence, domain)) continue; + const lead = YEARLESS_COLLECT_LEAD[domain]; + if (!lead) continue; + return { + method_id: PROBE_METHOD_ID[domain], + intent: "collect_method_evidence", + ask_theme: REVERSE_VERIFY_THEME[domain], + domain, + kind_hint: REVERSE_VERIFY_KIND[domain], + user_prompt_hint: collectHint(lead, REVERSE_VERIFY_VARGA[domain], "", input.evidence), + must_not_label: false, + choice_frame: null, + source: "oos_blind", + }; + } + return { + method_id: "dasha_events", + intent: "collect_method_evidence", + ask_theme: "dated_event", + domain: null, + kind_hint: null, + user_prompt_hint: collectHint( + "可以再说一件记得大概时间的经历。", + "本命 Dasha + 行运(方法1)", + "", + input.evidence, + ), + must_not_label: false, + choice_frame: null, + source: "oos_blind", + }; +} + export function spokenCollectFallbackFollowup(followup: MethodFollowup): MethodFollowup { return { method_id: followup.method_id, @@ -1289,6 +1384,7 @@ export function buildMethodFollowupPlan(input: { topCandidateTimes: input.topCandidateTimes, providedDomains: datedDomainsFromEvidence(input.evidence), evidence: input.evidence, + birthDate: input.birthDate, }) : { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] }; const rankedDiscriminators = rankedCatalog.locked; diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts index 252ee2b3..d9672ed5 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -33,7 +33,8 @@ export type ProbeRejectReason = | "zero_gain" | "insufficient_candidates" | "insufficient_outcomes" - | "yearless_ungrounded_contrast"; + | "yearless_ungrounded_contrast" + | "no_split_among_active"; export type StyleOptionsResult = | { ok: true; options: ProbeStyleOption[] } @@ -328,3 +329,56 @@ export function rankDiscriminatorScore(input: { repetitionPenalty: asked ? 0.45 : 0, }); } + +const CLOCK_TIME = /^(?:[01]?\d|2[0-3]):[0-5]\d$/; + +function clockTime(value: string): string | null { + const time = value.slice(0, 5); + if (!CLOCK_TIME.test(time)) return null; + return time.length === 5 ? time : time.padStart(5, "0"); +} + +function groupEntropy(sizes: readonly number[]): number { + const total = sizes.reduce((sum, item) => sum + item, 0); + if (total <= 0) return 0; + let entropy = 0; + for (const item of sizes) { + if (item <= 0) continue; + const p = item / total; + entropy -= p * Math.log2(p); + } + return Math.round(entropy * 10_000) / 10_000; +} + +export function informationGainAmongActive( + outcomes: readonly Readonly<{ + answer_class?: string; + outcomeId?: string; + supports?: readonly string[]; + supportsCandidateIds?: readonly string[]; + }>[], + activeTimes: readonly string[], +): { splits: boolean; informationGain: number } { + const active = new Set( + activeTimes.flatMap((item) => { + const time = clockTime(item); + return time ? [time] : []; + }), + ); + if (active.size < 2) return { splits: false, informationGain: 0 }; + const buckets = new Map(); + for (const time of active) { + const classes = outcomes.flatMap((row) => { + const klass = row.answer_class ?? row.outcomeId ?? ""; + if (klass === "unsure" || !klass) return []; + const supports = row.supports ?? row.supportsCandidateIds ?? []; + return supports.some((item) => clockTime(item) === time) ? [klass] : []; + }).sort(); + if (classes.length === 0) continue; + const key = classes.join("+"); + buckets.set(key, (buckets.get(key) ?? 0) + 1); + } + const sizes = [...buckets.values()]; + if (sizes.length < 2) return { splits: false, informationGain: 0 }; + return { splits: true, informationGain: groupEntropy(sizes) }; +} diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index c3a51965..2cda35ef 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -875,7 +875,7 @@ test("failed card persist still leaves a spoken collect next step", () => { assert.match(source, /if \(followup\?\.choice_frame\)/); assert.match(source, /spokenCollectFallbackFollowup/); assert.match(source, /spokenFollowupForUser\(spokenFollowup\)/); - assert.match(source, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); + assert.match(source, /nonConvergingRangeNarration/); assert.match(source, /persist next focus failed case=/); assert.doesNotMatch(source, /if \(fallback\.status === "created"/); const applied = source.slice(source.indexOf("async function persistApplied")); diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts index b56cf6d5..eaf5fcaa 100644 --- a/frontend/tests/rectification-candidate-contrast-packet.test.ts +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -93,7 +93,7 @@ test("finance remaining splits stay out unless volunteered", () => { assert.equal(shown[0]?.layer, "d2"); }); -test("existence probes leave the catalog once that domain already has dated evidence", () => { +test("existence probes leave the catalog once that year already has dated evidence, not the whole domain", () => { const packet = buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:14", engineProbes: [{ @@ -114,10 +114,32 @@ test("existence probes leave the catalog once that domain already has dated evid { layer: "d24", at: "05:14", from_sign: "金牛座", to_sign: "双子座" }, ], }); - assert.equal(packet.probes.some((item) => item.semanticKey.includes("career.2023")), false); + assert.equal(packet.probes.some((item) => item.semanticKey.includes("career.2023")), true); + const askedSameYear = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:14", + engineProbes: [{ + semantic_key: "career.2023.dasha_activation", + domain: "career", + year: 2023, + user_meaning: "时间范围锁定 2023 年前后。", + information_gain: 0.56, + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:14"] }, + { answer_class: "no", supports: ["05:14"], conflicts: ["05:00"] }, + ], + }], + askedKeys: ["career.2023"], + providedDomains: ["career"], + candidateTimes: ["05:00", "05:07", "05:14"], + transitions: [ + { layer: "d24", at: "05:07", from_sign: "白羊座", to_sign: "金牛座" }, + { layer: "d24", at: "05:14", from_sign: "金牛座", to_sign: "双子座" }, + ], + }); + assert.equal(askedSameYear.probes.some((item) => item.semanticKey.includes("career.2023")), false); assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d24."))); const inspected = inspectDiscriminatorProbes(packet); - assert.equal(inspected.selected, null); + assert.equal(inspected.selected?.semanticKey.includes("career.2023"), true); assert.equal(inspected.dropped.some((item) => ( item.semantic_key.startsWith("varga.d24.") && item.reason === "yearless_ungrounded_contrast" )), true); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 43052bd4..6fa2efe8 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -271,10 +271,12 @@ const DATED_RELOCATION_2016: ConflictProbe = { domain: "relocation", year: 2016, question: "2016 年前后有没有搬家或长期住到外地?", - candidate_ids: ["05:00", "05:07"], + candidate_ids: [ + "04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15", + ], expected_outcomes: [ - { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, - { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "yes", supports: ["04:47", "04:51", "04:53", "04:59"], conflicts: ["05:00", "05:07", "05:12", "05:14", "05:15"] }, + { answer_class: "no", supports: ["05:00", "05:07", "05:12", "05:14", "05:15"], conflicts: ["04:47", "04:51", "04:53", "04:59"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.8, diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 20b90100..e6c2fab0 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -338,9 +338,9 @@ test("scored inference catalog outranks a low-gain Python career probe when snap }; const packet = contrastPacketFromDossier(dossier); const inspected = inspectDiscriminatorProbes(packet); - assert.equal(packet.probes.some((probe) => probe.semanticKey.includes("career.2023")), false); + assert.equal(packet.probes.some((probe) => probe.semanticKey.includes("career.2023")), true); assert.ok(packet.probes.some((probe) => probe.semanticKey.startsWith("varga.d24."))); - assert.equal(inspected.selected, null); + assert.equal(inspected.selected?.semanticKey.includes("career.2023"), true); assert.equal(inspected.dropped.some((item) => ( item.semantic_key.startsWith("varga.d24.") && item.reason === "yearless_ungrounded_contrast" )), true); diff --git a/frontend/tests/rectification-discriminator-followup-consistency.test.ts b/frontend/tests/rectification-discriminator-followup-consistency.test.ts index e1d8234f..ebddbb76 100644 --- a/frontend/tests/rectification-discriminator-followup-consistency.test.ts +++ b/frontend/tests/rectification-discriminator-followup-consistency.test.ts @@ -471,8 +471,8 @@ test("agent message path no longer returns the discriminator dead-end copy", () route.indexOf("const requestTime"), ); assert.doesNotMatch(fastPath, /目前没有可继续区分/); - assert.match(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); - assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/); + assert.match(route, /nonConvergingRangeNarration/); + assert.match(fastPath, /persistNextInterviewIfIdle/); assert.match(fastPath, /!plan\.next_followup/); assert.ok(fastPath.includes("ask_candidate_discriminator")); assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 66ea3574..505b2155 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -1171,10 +1171,10 @@ test("evidence batch returns the persisted choice prompt as open_question", asyn semantic_key: "relocation.2023.dasha_activation", candidate_set_version: "set-test", candidate_split_hash: "set-test:relocation:2023", - candidate_ids: ["04:50", "05:20"], + candidate_ids: ["04:50", "04:51"], expected_outcomes: [ - { answer_class: "yes", supports: ["04:50"], conflicts: ["05:20"] }, - { answer_class: "no", supports: ["05:20"], conflicts: ["04:50"] }, + { answer_class: "yes", supports: ["04:50"], conflicts: ["04:51"] }, + { answer_class: "no", supports: ["04:51"], conflicts: ["04:50"] }, ], style_options: DYNAMIC_STYLE_OPTIONS, choice_kind: "existence", diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 650d9b6d..a34c2601 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -355,14 +355,15 @@ test("decideFromDossier offers a range when training is complete and the ask lay assert.equal(decision.canConfirmExactMinute, false); }); -test("agent route narrates the binary range exit when no renderable distinguish card remains", () => { +test("agent route narrates a numeric range exit and persists a collect when no renderable distinguish card remains", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); - assert.match(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); + assert.match(route, /nonConvergingRangeNarration/); + assert.doesNotMatch(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); - assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION|isNonConvergingRangeOffer|canOfferRange/); + assert.match(fastPath, /persistNextInterviewIfIdle|isNonConvergingRangeOffer|canOfferRange/); assert.doesNotMatch(fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); }); diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts new file mode 100644 index 00000000..287342ec --- /dev/null +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -0,0 +1,615 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +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 { + inspectDiscriminatorProbes, + buildCandidateContrastPacket, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { + decideRectification, + isNonConvergingRangeOffer, + nonConvergingRangeNarration, + REPRESENTATIVE_MINUTE_DISCLAIMER, +} from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; +import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; +import { + decideAfterInferenceChange, + decideFromDossier, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + exhaustionSpokenCollectFollowup, + projectRectificationChoiceCard, + spokenFollowupForUser, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + 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 TIMES = [ + "04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15", +] as const; +const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]); +const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const; +const SCORES: Record = { + "04:47": 16, + "04:51": 20, + "04:53": 16, + "04:59": 10, + "05:00": 4, + "05:07": 3, + "05:12": 2, + "05:14": 1, + "05:15": 1, +}; +const PROBABILITY: Record = { + "04:47": 0.25, + "04:51": 0.4, + "04:53": 0.25, + "04:59": 0.1, +}; + +const EVIDENCE = [ + { + id: "e-career-entry", + status: "confirmed" as const, + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + summary: "2020-04-01 career_entry", + }, + { + id: "e-career-exit", + status: "confirmed" as const, + domain: "career", + datePrecision: "month" as const, + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + summary: "2020-10-01 career_exit", + }, + { + id: "e-rel-start", + status: "confirmed" as const, + domain: "relationship", + datePrecision: "month" as const, + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + summary: "2024-05-01 relationship_start", + }, + { + id: "e-rel-end", + status: "confirmed" as const, + domain: "relationship", + datePrecision: "day" as const, + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + summary: "2024-08-08 relationship_end", + }, +] as const; + +const DUAL_EXIT = "可以先按当前区间看盘,也可以再补一件记得时间的经历"; + +function existenceProbe(input: { + key: string; + year: number; + question: string; + gain: number; + source: string; + yes: readonly string[]; + no: readonly string[]; +}): ConflictProbe { + return { + id: `probe:${input.key}`, + semantic_key: input.key, + candidate_split_hash: input.key, + domain: "career", + year: input.year, + question: input.question, + candidate_ids: [...new Set([...input.yes, ...input.no])], + expected_outcomes: [ + { answer_class: "yes", supports: input.yes, conflicts: input.no }, + { answer_class: "weak_yes", supports: [], conflicts: [] }, + { answer_class: "no", supports: input.no, conflicts: input.yes }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: input.gain, + source: input.source, + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, + }; +} + +const D9: ConflictProbe = { + id: "contrast:varga.d9.巨蟹座/狮子座", + semantic_key: "varga.d9.巨蟹座/狮子座", + candidate_split_hash: "varga.d9.巨蟹座/狮子座", + domain: "relationship", + year: 0, + question: "亲密关系里更接近下面哪一种相处方式?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.1, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const D10: ConflictProbe = { + id: "contrast:varga.d10.天秤座/天蝎座", + semantic_key: "varga.d10.天秤座/天蝎座", + candidate_split_hash: "varga.d10.天秤座/天蝎座", + domain: "career", + year: 0, + question: "平时做事更接近下面哪一种职责风格?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.05, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const CAREER_2023_05 = existenceProbe({ + key: "career.2023.05.dasha_boundary", + year: 2023, + question: "2023 年 5 月前后有没有入职或换工作?", + gain: 0.9, + source: "dasha_boundary", + yes: ["05:00"], + no: ["05:07"], +}); + +const RELOCATION_2015_05: ConflictProbe = { + ...existenceProbe({ + key: "relocation.2015.05.dasha_boundary", + year: 2015, + question: "2015 年 5 月前后有没有搬家或长期住到外地?", + gain: 0.7, + source: "dasha_boundary", + yes: ["05:00"], + no: ["05:07"], + }), + domain: "relocation", +}; + +/** Live remaining probe: splits after/before 05:00. Zero split on 04:47–04:59. */ +const CAREER_2024_04 = existenceProbe({ + key: "career.2024.04.dasha_boundary", + year: 2024, + question: "2024 年 4 月前后有没有入职或换工作?", + gain: 1.1712, + source: "dasha_boundary", + yes: ["05:00", "05:07", "05:12", "05:14", "05:15"], + no: ["04:47", "04:51", "04:53", "04:59"], +}); + +/** Live remaining probe: isolates 05:15. Zero split on 04:47–04:59. */ +const CAREER_2023_ACTIVATION = existenceProbe({ + key: "career.2023.dasha_activation", + year: 2023, + question: "2023 年前后大运有没有启动?", + gain: 0.56, + source: "dasha_activation", + yes: ["05:15"], + no: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"], +}); + +/** P1 fixture: dated career probe that still splits the active four minutes. */ +const CAREER_ACTIVE_SPLIT = existenceProbe({ + key: "career.2022.dasha_boundary", + year: 2022, + question: "2022 年前后有没有入职或换工作?", + gain: 1.4, + source: "dasha_boundary", + yes: ["04:47", "04:51"], + no: ["04:53", "04:59"], +}); + +function uuidAt(index: number): string { + return `88888888-8888-4888-8888-8888888888${(10 + index).toString(16).padStart(2, "0")}`; +} + +function liveState(remaining: readonly ConflictProbe[]): InferenceState { + const probes = [D9, D10, CAREER_2023_05, RELOCATION_2015_05, ...remaining]; + const rankedActive = [...ACTIVE].sort((left, right) => ( + (PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0) + || (SCORES[right] ?? 0) - (SCORES[left] ?? 0) + || left.localeCompare(right) + )); + const candidates = TIMES.map((time, index) => { + const eliminated = ELIMINATED.has(time); + const activeRank = (rankedActive as readonly string[]).indexOf(time); + return { + id: time, + time, + cluster_range: [time, time] as const, + prior_score: SCORES[time] ?? 0, + posterior_score: SCORES[time] ?? 0, + probability: eliminated ? 0 : (PROBABILITY[time] ?? 0), + status: eliminated ? "eliminated" as const : "active" as const, + rank: eliminated ? ACTIVE.length + index : activeRank + 1, + strong_conflict_count: eliminated ? 3 : 0, + }; + }); + const answered = [D9, D10, CAREER_2023_05, RELOCATION_2015_05].map((probe) => ({ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: "no" as const, + classified_from: "choice" as const, + })); + const raw = { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: candidateSetId("04:47", "05:15", TIMES), + revision: 5, + phase: "discrimination" as const, + result_status: "discriminating" as const, + range_start: "04:47", + range_end: "05:15", + candidates, + events: [ + { id: "e-career-entry", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: "e-career-exit", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "training" as const }, + ], + probes, + answered_probes: answered, + rounds: [], + last_inference_round: null, + entropy: 1.2, + representative_time: "04:51", + credible_range: ["04:47", "04:53"] as const, + holdout_passed: null, + }; + const loaded = asInferenceState(raw); + assert.ok(loaded, "live inference fixture must pass asInferenceState"); + return loaded; +} + +function liveDossier( + remaining: readonly ConflictProbe[], + extra: { + declinedTopics?: ReadonlyArray>; + status?: string; + eventProbes?: boolean; + } = {}, +): DecisionDossier { + const state = liveState(remaining); + const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); + return { + evidence: EVIDENCE, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: extra.declinedTopics ?? [{ target_domain: "family", status: "declined" }], + }, + 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: Math.round(SCORES[time] ?? 0), + })), + representativeTime: "04:51", + decisionReceipt: { + accept_allowed: true, + propose_allowed: true, + selection_allowed: true, + inference_state: state, + ...(extra.eventProbes + ? { + discriminating_event_probes: remaining.map((probe) => ({ + year: probe.year, + year_label: `${probe.year} 年前后`, + domain: probe.domain, + event_family: "入职、换工作或职责加重", + 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, + })), + } + : {}), + }, + }, + case: { acceptedTime: null, status: extra.status }, + }; +} + +function snapshotCandidates() { + return TIMES.map((time, index) => ({ + candidate_id: uuidAt(index), + rank: index + 1, + time, + relative_support: Math.round(SCORES[time] ?? 0), + tied_minute_count: 1, + })); +} + +function rpcDossier(decision: DecisionDossier) { + const evidence = EVIDENCE.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind, + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: `${item.occurredFrom} ${item.eventKind}`, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-08-29T00:00:00.000Z", + })); + return dossierFixture({ + evidence, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "04:51", + evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), + candidates: snapshotCandidates(), + decisionReceipt: { + accept_allowed: true, + propose_allowed: true, + selection_allowed: true, + ...(decision.latestResult?.decisionReceipt ?? {}), + }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: null, + declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-08-30T00:00:00.000Z", + }, + }); +} + +test("skill version stays 10.0.13 for the range-offer dead-end fix", () => { + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13"); +}); + +test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => { + const decision = "../src/lib/rectification-agentic/core/rectification-decision.ts"; + const answer = "../src/lib/rectification-agentic/v9/answer-choice.ts"; + const route = "../src/app/api/rectification/agent/route.ts"; + for (const relative of [decision, answer, route]) { + const source = readFileSync(new URL(relative, import.meta.url), "utf8"); + assert.doesNotMatch(source, new RegExp(DUAL_EXIT)); + assert.doesNotMatch(source, /NON_CONVERGING_RANGE_NARRATION|DISCRIMINATOR_EXHAUSTED_NARRATION/); + assert.match(source, /nonConvergingRangeNarration/); + } + const text = nonConvergingRangeNarration({ + credibleRange: ["04:47", "04:53"], + representativeTime: "04:51", + }); + assert.match(text, /04:47–04:53/); + assert.match(text, /04:51/); + assert.match(text, new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER)); + assert.doesNotMatch(text, /可以先按当前区间看盘/); +}); + +test("zero-split among active is dropped with an explicit reason, not kept silent", () => { + const after = informationGainAmongActive(CAREER_2024_04.expected_outcomes, ACTIVE); + const activation = informationGainAmongActive(CAREER_2023_ACTIVATION.expected_outcomes, ACTIVE); + const split = informationGainAmongActive(CAREER_ACTIVE_SPLIT.expected_outcomes, ACTIVE); + assert.equal(after.splits, false); + assert.equal(activation.splits, false); + assert.equal(split.splits, true); + const packet = buildCandidateContrastPacket({ + candidateSetVersion: candidateSetId("04:47", "05:15", TIMES), + engineProbes: [CAREER_2024_04, CAREER_2023_ACTIVATION].map((probe) => ({ + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + domain: probe.domain, + year: probe.year, + user_meaning: probe.question, + information_gain: probe.information_gain, + expected_outcomes: probe.expected_outcomes, + choice_kind: probe.choice_kind, + style_options: probe.style_options, + })), + providedDomains: ["career", "relationship"], + candidateTimes: [...TIMES], + }); + assert.equal(packet.probes.some((item) => item.semanticKey === CAREER_2024_04.semantic_key), true); + const inspected = inspectDiscriminatorProbes(packet, { topCandidateTimes: ACTIVE }); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active" + )), true); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active" + )), true); +}); + +test("P1: dated career probes live only in inference_state still ask when they split active minutes", () => { + const dossier = liveDossier([CAREER_ACTIVE_SPLIT]); + const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + assert.equal(fromDossier.nextAction, "ask_candidate_discriminator"); + assert.equal(fromDossier.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key); + assert.equal(isNonConvergingRangeOffer(fromDossier), false); + const after = decideAfterInferenceChange({ + dossier, + state: liveState([CAREER_ACTIVE_SPLIT]), + userStopped: false, + birthDate: "1997-08-08", + }); + assert.equal(after.nextAction, "ask_candidate_discriminator"); + assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key); +}); + +test("live remaining probes with zero active split enter dropped_probes and the range-offer branch", () => { + const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION]; + const dossier = liveDossier(remaining); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + assert.equal(decision.probe, null); + assert.equal(decision.nextAction, "offer_provisional_range"); + assert.equal(isNonConvergingRangeOffer(decision), true); + assert.equal(decision.canAdopt, false); + assert.equal(decision.selectionAllowed, false); + assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]); + assert.equal(decision.representativeTime, "04:51"); + assert.equal(decision.droppedProbes.some((item) => ( + item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active" + )), true); + assert.equal(decision.droppedProbes.some((item) => ( + item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active" + )), true); +}); + +test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric range", async () => { + const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + assert.equal(isNonConvergingRangeOffer(decision), true); + const card = projectRectificationChoiceCard({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + selectionAllowed: decision.selectionAllowed, + }); + assert.equal(card, null); + const collect = exhaustionSpokenCollectFollowup({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + }); + assert.equal(collect?.domain, "education"); + assert.equal(collect?.intent, "collect_method_evidence"); + assert.equal(collect?.choice_frame, null); + const spoken = spokenFollowupForUser(collect); + assert.ok(spoken); + + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(dossier), + get_agentic_rectification_case_compute: () => computeFixture(), + 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, + 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-08-30T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }), + }); + const idle = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(idle.persisted, true); + assert.equal(idle.choiceReady, false); + assert.match(idle.hostNarration ?? "", /04:47–04:53/); + assert.match(idle.hostNarration ?? "", /04:51/); + assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER)); + assert.match(idle.hostNarration ?? "", /升学|转学|考试/); + assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT)); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.equal(setFocus?.args.p_intent, "collect_method_evidence"); + assert.equal(setFocus?.args.p_target_domain, "education"); + const schema = setFocus?.args.p_expected_answer_schema as Record | undefined; + const question = projectCurrentQuestion({ + id: FOCUS_ID, + questionId: String(setFocus?.args.p_question_id ?? ""), + intent: "collect_method_evidence", + targetDomain: "education", + expectedAnswerSchema: schema ?? null, + }); + assert.equal(question?.kind, "collect_spoken"); + assert.ok(question?.prompt); + assert.equal(Boolean(idle.hostNarration) && Boolean(question), true); +}); + +test("userStopped still completes as provisional_range_user_stopped with canAdopt", () => { + const stopped = decideRectification({ + methodCoverageAll: false, + trainingGateOpen: true, + userStopped: true, + engineAcceptAllowed: true, + engineProposeAllowed: true, + candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })), + }); + assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped"); + assert.equal(stopped.canAdopt, true); + assert.equal(stopped.canOfferRange, true); + assert.equal(isNonConvergingRangeOffer(stopped), false); + const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" }); + const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped"); + assert.equal(fromDossier.canAdopt, true); +}); + +test("idle persist still decides from the dossier once and does not invent collect_evidence", () => { + const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + const idle = source.slice( + source.indexOf("export async function persistNextInterviewIfIdle"), + source.indexOf("async function persistApplied"), + ); + assert.equal(idle.split("decideFromDossier").length - 1, 1); + assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/); + assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/); + assert.match(idle, /persistExhaustionCollect/); +}); diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index 55334e7c..02fc2333 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -138,8 +138,8 @@ test("production intent handling contains no semantic regex or positional text p assert.ok(route.indexOf("persistServerOwnedFocus") < route.indexOf("runV9AgentTurn({")); assert.ok(fastPath.includes("ask_candidate_discriminator")); assert.doesNotMatch(fastPath, /目前没有可继续区分/); - assert.match(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); - assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/); + assert.match(route, /nonConvergingRangeNarration/); + assert.match(fastPath, /persistNextInterviewIfIdle/); assert.match(fastPath, /!plan\.next_followup/); assert.doesNotMatch(route, /classified\.answer_class!/); });