From 02d4c916266657132ceb3d72d2e2e4d4c5093558 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 30 Aug 2026 03:05:28 +0800 Subject: [PATCH] fix(rectification): stop occupation coverage from locking questions and the range exit Occupation answers were stored as career, so coverage never closed, yearless cards never fired, and the decision layer suppressed an engine-allowed range. Normalize occupation-collect writes, allow yearless cards once training is open, offer a range when no distinguish card remains, and align the public house table to the representative minute. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 + .../src/app/api/rectification/agent/route.ts | 12 +- .../core/decide-next-action.ts | 2 + .../core/rectification-decision.ts | 54 +++ .../rectification-agentic/v9/answer-choice.ts | 9 +- .../v9/decision-from-dossier.ts | 24 +- .../v9/evidence-model.ts | 41 ++ .../v9/interview-state.ts | 1 + .../v9/method-followup.ts | 96 +++- .../src/lib/rectification-candidate-result.ts | 45 +- frontend/src/mastra/rectification-v9-tools.ts | 106 +++-- .../tests/rectification-answer-choice.test.ts | 2 +- .../tests/rectification-collect-stall.test.ts | 23 +- ...discriminator-followup-consistency.test.ts | 2 +- ...ification-occupation-coverage-exit.test.ts | 449 ++++++++++++++++++ ...ctification-turn-intent-classifier.test.ts | 2 +- 16 files changed, 815 insertions(+), 69 deletions(-) create mode 100644 frontend/tests/rectification-occupation-coverage-exit.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0c05b007..17581bc1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6767,3 +6767,19 @@ - 复发自:BUG-432(无选择卡的问题曾要求由 Agent 正文问出,前端仍不渲染 `current_question`) - 修复版本:待发布 +## BUG-442 | 职业采集关不上、无年份卡出不来、覆盖门压掉区间出口 + +- 状态:resolved +- 首次发现:2026-08-30 +- 最近更新:2026-08-30 +- 影响面:`applyOccupationCollectLedgerNorm` / `occupationCovered`、`buildMethodFollowupPlan` 无年份分盘卡、`decideRectification` 覆盖门、POST `/api/rectification/agent` 区间出口、`houseTableAlignedToMinute` / `publicChartForMinute` +- 用户现象:账本已有足够可计分事件和多个领域,助手仍反复追问职业口述题;候选几乎均匀,`margin_percent` 停在门槛以下,entropy 不降;`can_offer_range` / `can_adopt` / `propose_allowed` / `selection_allowed` 全为 false,用户从头到尾没被告知可以按当前区间收口。对外代表分钟与宫位表也可能不是同一时刻。 +- 触发条件:职业采集焦点下用户给出职业性质描述,模型把证据记成 `domain=career` 的入职/换工作;训练事件已达标,剩余高信息量探针全是无年份分盘题;引擎回执已 `accept_allowed` / `propose_allowed`,决策层仍因 occupation 覆盖 flag 落 `collect()`。 +- 根因:四段同时成立。(1) 覆盖判定 `isOccupationNote` 要求 `domain=occupation`,职业口述回答被记成 career 域,occupation 方法层永不关闭,`next_followup` 恒为职业口述采集题。(2) 无年份分盘卡的出题条件绑在 `coverageComplete` 上,覆盖不齐则 d24/d7/d4/d5 一道都出不来,margin 无法被这些探针拉开。(3) `decideRectification` 撞上 `coverageBlocks` 直接 `collect()`,把 `canOfferRange` 压成 false;同一份引擎回执已经放行 accept/propose,决策层因覆盖 flag 单方面压掉区间出口。(4) 对外投影的 `representative_time` 取推理层代表分钟,`house_table` / `natal_recast` 却可能是另一分钟算出的盘面。 +- 修复:P0:occupation 采集焦点下,服务端把该轮职业性质描述归一成 `domain=occupation` / `eventKind=occupation_note`(无日期允许,occupation_note 不参与计分)。覆盖端:已有确认 career 证据且 occupation 采集焦点已按 questionId / target_domain 关闭时也算覆盖,不读正文、不用关键词。P1:训练门开时允许出无年份分盘卡,不再要求覆盖齐;训练门未开仍不出,避免回到 BUG-405/407。同领域可渲染计分卡仍优先于不计分口述题(BUG-440)。P2:训练门已开且出题层给不出带 choice_frame 的可渲染区分卡时,允许 `canOfferRange` 并播报「可以先按当前区间看盘,也可以再补一件记得时间的经历」;引擎 accept/propose 已放行而覆盖 flag 要压掉时至少放行 `canOfferRange`。`canAdopt` 与 confirmation gate 维持 fail-closed。P3:对外 `representative_time`、`house_table`、`natal_recast` 必须是同一分钟;对不上则取 `house_tables_by_time` 对应表,否则标成不可用。Skill 保持 10.0.13。未引入语义正则、关键词表、A/B/C/D 位置推断或轮次状态机。 +- 验证:`frontend/tests/rectification-occupation-coverage-exit.test.ts` 用本例 15 件可计分、4 域账本锁:occupation 采集回答落 occupation_note;career 证据 + 已关闭 occupation 焦点覆盖成立;career 单独仍不覆盖;训练门开且覆盖未齐能出无年份分盘卡,训练门关仍不出;训练门开且无可渲染区分卡时 `canOfferRange` 为真、`canAdopt` / `canConfirmExactMinute` 为假;路由播报二选一;代表分钟与 `house_table` / `natal_recast` 同源,构造不一致回执不得直接发出。collect focus 仍不是选择卡。BUG-440 collect-stall、BUG-437 hash、D12 同领域卡优先保持绿。 +- 防复发:覆盖判定不得依赖模型选对 domain。同一个覆盖 flag 不得同时锁死出题与出口。训练门已开且无可渲染区分卡时必须给用户出路。对外代表分钟与盘面必须同源。不得用正文文本判断覆盖或意图。 +- 相关记录:BUG-404、BUG-407、BUG-410、BUG-432、BUG-437、BUG-440、BUG-441 +- 复发自:BUG-440(覆盖未齐时的收口分支存在,但 occupation 记账域错误导致永远走不到;无年份卡仍被同一覆盖门锁死) +- 修复版本:待发布 + diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index cdc161ce..fe9846d4 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -34,11 +34,12 @@ 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"; export const runtime = "nodejs"; export const maxDuration = 240; -export const DISCRIMINATOR_EXHAUSTED_NARRATION = "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。"; +export const DISCRIMINATOR_EXHAUSTED_NARRATION = "可以先按当前区间看盘,也可以再补一件记得时间的经历"; function completedMessageResponse(text: string, requestId: string, caseId: string) { const body = [ @@ -429,6 +430,14 @@ export async function POST(request: Request) { // Ranking stays fail-open when compute is unavailable. } const decision = decideFromDossier(dossier, { birthDate }); + if (isNonConvergingRangeOffer(decision)) { + await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: DISCRIMINATOR_EXHAUSTED_NARRATION, + }); + return completedMessageResponse(DISCRIMINATOR_EXHAUSTED_NARRATION, requestId, caseId); + } if (decision.nextAction === "ask_candidate_discriminator") { const catalog = rectificationFollowupCatalog( dossier.latestResult, @@ -437,6 +446,7 @@ export async function POST(request: Request) { const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, birthDate, diff --git a/frontend/src/lib/rectification-agentic/core/decide-next-action.ts b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts index 916d98e0..dbf0ce4c 100644 --- a/frontend/src/lib/rectification-agentic/core/decide-next-action.ts +++ b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts @@ -53,6 +53,8 @@ export function decideNextAction(input: DecideNextActionInput): RectificationNex holdoutValidation: input.holdoutValidation, accepted: input.accepted, inferenceCredibleRange: input.inferenceCredibleRange, + engineAcceptAllowed: input.selectionAllowed, + engineProposeAllowed: input.proposeAllowed, }); return { type: decision.nextAction, separation: decision.separation, probe: decision.probe }; } diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 76d48908..277db804 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -78,6 +78,9 @@ export type DecideRectificationInput = Readonly<{ holdoutValidation?: HoldoutValidationStatus; accepted?: boolean; inferenceCredibleRange?: readonly [string, string] | null; + engineAcceptAllowed?: boolean; + engineProposeAllowed?: boolean; + datedMethodCollectOpen?: boolean; }>; export function decideRectification(input: DecideRectificationInput): RectificationDecision { @@ -118,6 +121,17 @@ export function decideRectification(input: DecideRectificationInput): Rectificat const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage) || input.trainingGateOpen === false; if (coverageBlocks && !(userStopped && input.candidateScores.length > 0)) { + const trainingOpen = input.trainingGateOpen !== false; + const engineOffers = input.engineAcceptAllowed === true || input.engineProposeAllowed === true; + if ( + trainingOpen + && input.candidateScores.length > 0 + && !probe + && engineOffers + && input.datedMethodCollectOpen !== true + ) { + return offerRangeWithoutAdopt(separation, holdout, range); + } return collect(separation, holdout, range, probe); } if (input.snapshotCurrent === false) { @@ -198,6 +212,46 @@ function collect( }; } +function offerRangeWithoutAdopt( + separation: CandidateSeparation, + holdout: HoldoutValidationStatus, + range: readonly [string, string] | null, +): RectificationDecision { + return { + phase: "discrimination", + nextAction: "offer_provisional_range", + sessionOutcome: "provisional_range", + resultStatus: "insufficient_evidence", + canOfferRange: true, + canAdopt: false, + canConfirmExactMinute: false, + selectionAllowed: false, + proposeAllowed: false, + precisionStage: "theme_refine", + activeFocusPolicy: "close", + completionStatus: null, + validated: false, + credibleRange: range, + representativeTime: separation.representativeTime, + separation, + probe: null, + holdoutValidation: holdout, + droppedProbes: [], + }; +} + +export function isNonConvergingRangeOffer(decision: Pick< + RectificationDecision, + "canOfferRange" | "canAdopt" | "canConfirmExactMinute" | "nextAction" +>): boolean { + return decision.canOfferRange + && !decision.canAdopt + && !decision.canConfirmExactMinute + && decision.nextAction === "offer_provisional_range"; +} + +export const NON_CONVERGING_RANGE_NARRATION = "可以先按当前区间看盘,也可以再补一件记得时间的经历"; + function discriminate( separation: CandidateSeparation, holdout: HoldoutValidationStatus, diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index de44b2e8..2c664797 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 { publicNextAction } from "../core/rectification-decision.ts"; +import { isNonConvergingRangeOffer, publicNextAction } from "../core/rectification-decision.ts"; import { applyChoiceWithoutEvidence, previousInferenceFromReceipt, @@ -302,6 +302,7 @@ export async function persistNextInterviewAfterChoice(input: { evidence: input.dossier.evidence, activeFocus: null, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, sessionOutcome, ...catalog, birthDate, @@ -348,7 +349,7 @@ export async function persistNextInterviewAfterChoice(input: { } if (!followup) { return { - hostNarration: "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。", + hostNarration: "可以先按当前区间看盘,也可以再补一件记得时间的经历", choiceReady: false, }; } @@ -465,11 +466,15 @@ export async function persistNextInterviewIfIdle(input: { birthDate = null; } const decision = decideFromDossier(dossier, { birthDate }); + if (isNonConvergingRangeOffer(decision)) { + return { persisted: false, choiceReady: false }; + } const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, activeFocus: null, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, ...catalog, birthDate, 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 47f9f352..cb5b0971 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -31,6 +31,7 @@ import { import { blockingMethodsCovered, buildMethodFollowupPlan, + datedMethodCollectOpen, } from "./method-followup"; import { buildConfirmationGate } from "./confirmation-gate"; import { @@ -95,7 +96,17 @@ export type DecisionDossier = Readonly<{ }>; export function candidateScoresFromDossier(latest: DecisionDossier["latestResult"]) { - return authoritativeCandidateProjection(latest).scores; + const projection = authoritativeCandidateProjection(latest); + if (projection.scores.length > 0) return projection.scores; + if (previousInferenceFromReceipt(latest?.decisionReceipt ?? null)) return []; + return (latest?.candidates ?? []).flatMap((item) => ( + typeof item.time === "string" && item.time.trim() + ? [{ + time: item.time, + score: item.posterior_score ?? item.relativeSupport ?? 0, + }] + : [] + )); } const CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/; @@ -393,6 +404,7 @@ function discriminatorProbeIfFollowupCanAsk(input: { const plan = buildMethodFollowupPlan({ evidence: input.dossier.evidence, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, ...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}), @@ -424,6 +436,7 @@ export function decideFromDossier( const collecting = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "collect_evidence", }); const latest = dossier.latestResult; @@ -461,6 +474,10 @@ export function decideFromDossier( holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts), accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, + engineAcceptAllowed: latest?.selectionAllowed === true + || latest?.decisionReceipt?.accept_allowed === true, + engineProposeAllowed: latest?.decisionReceipt?.propose_allowed === true, + datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), }), droppedProbes: gated.dropped, }; @@ -475,6 +492,7 @@ export function decideAfterInferenceChange(input: { const collecting = buildMethodFollowupPlan({ evidence: input.dossier.evidence, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "collect_evidence", }); if (!input.state) { @@ -515,6 +533,10 @@ export function decideAfterInferenceChange(input: { inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, accepted: Boolean(input.dossier.case.acceptedTime), + engineAcceptAllowed: input.dossier.latestResult?.selectionAllowed === true + || input.dossier.latestResult?.decisionReceipt?.accept_allowed === true, + engineProposeAllowed: input.dossier.latestResult?.decisionReceipt?.propose_allowed === true, + datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), }), droppedProbes: gated.dropped, }; diff --git a/frontend/src/lib/rectification-agentic/v9/evidence-model.ts b/frontend/src/lib/rectification-agentic/v9/evidence-model.ts index 05035212..af7f11b1 100644 --- a/frontend/src/lib/rectification-agentic/v9/evidence-model.ts +++ b/frontend/src/lib/rectification-agentic/v9/evidence-model.ts @@ -319,3 +319,44 @@ export function evidenceSubjectForDomain( if (domain === "other") return "other"; return "self"; } + +export type OccupationCollectFocus = Readonly<{ + intent?: string | null; + targetDomain?: string | null; + targetKind?: string | null; + questionId?: string | null; +}>; + +export function isOccupationCollectFocus( + focus: OccupationCollectFocus | null | undefined, +): boolean { + if (!focus) return false; + const collectQuestion = typeof focus.questionId === "string" + && focus.questionId.startsWith("collect:occupation:"); + if (focus.intent && focus.intent !== "collect_method_evidence") return false; + return focus.targetDomain === "occupation" + || focus.targetKind === "occupation_note" + || collectQuestion; +} + +/** When the answered focus is occupation collect, do not trust the model domain. */ +export function applyOccupationCollectLedgerNorm(focus: OccupationCollectFocus | null | undefined, items: readonly T[]): T[] { + if (!isOccupationCollectFocus(focus)) return [...items]; + return items.map((item) => { + if (item.domain !== "career" && item.domain !== "occupation") return item; + return { + ...item, + domain: "occupation", + eventKind: "occupation_note" as EvidenceKind, + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + }; + }); +} diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 5b2fafcb..4a2ea82b 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -76,6 +76,7 @@ export function choiceCardFromCaseDossier(dossier: { evidence: dossier.evidence, activeFocus: dossier.conversationSummary.activeFocus, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: decision.sessionOutcome, ...catalog, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 01f416ef..37acfc28 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -22,7 +22,10 @@ * Finance/health score if volunteered; they are not method-layer rotation. * Method coverage finishes before repeating a precision-stage ask. * Appearance and marks are skipped_by_policy. Horary does not block offering - * time cards. Occupation does block cards until a note exists. + * time cards. Occupation still blocks adopt and confirmation until a note + * exists, but once the training gate is open it must not also block yearless + * varga cards. Career evidence plus a closed occupation collect focus covers + * occupation without depending on the model picking domain=occupation. * Method coverage asks for dated events in natural language. * Known-event quality probes (exam went badly for a year already * in the ledger) are not reverse-inference cards. Dasha existence @@ -45,7 +48,10 @@ * discriminator must resolve from the contrast packet when Python event * probes have no row in that domain. Scoring reverse-inference cards need * the engine year or month. Yearless varga cards stay out of the dated - * ranking until coverage is complete. If the next ask would be a spoken + * ranking until the training gate is open. Uncovered relationship, career, + * and family still ask a same-domain card or spoken collect first. + * Occupation collect must not block those yearless cards once training + * events already pass. If the next ask would be a spoken * collect in the same domain, prefer a renderable yearless scoring card * instead of an unscoreable collect question. */ @@ -302,6 +308,16 @@ export function blockingMethodsCovered(methods: readonly MethodCoverage[]): bool return !methods.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"); } +export function datedMethodCollectOpen(methods: readonly MethodCoverage[]): boolean { + return methods.some((item) => ( + (item.method_id === "dasha_events" + || item.method_id === "d9_relationship" + || item.method_id === "d10_career" + || item.method_id === "relatives") + && item.status === "uncovered" + )); +} + function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean { return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health"); } @@ -325,6 +341,29 @@ function declinedDomains( return domains; } +function occupationCollectFocusClosed( + topics: readonly Readonly>[], +): boolean { + return topics.some((topic) => { + const status = typeof topic.status === "string" ? topic.status : ""; + if (status === "active") return false; + const domain = typeof topic.target_domain === "string" + ? topic.target_domain + : typeof topic.targetDomain === "string" + ? topic.targetDomain + : null; + const questionId = typeof topic.questionId === "string" + ? topic.questionId + : typeof topic.question_id === "string" + ? topic.question_id + : ""; + const intent = typeof topic.intent === "string" ? topic.intent : ""; + if (questionId.startsWith("collect:occupation:")) return true; + return domain === "occupation" + && (intent === "collect_method_evidence" || intent === ""); + }); +} + const MAX_REVERSE_VERIFY = 2; const REVERSE_VERIFY_THEME = { @@ -988,6 +1027,9 @@ export function decideConversationalSession(input: { : discriminatorFromFollowup(input.nextFollowup), holdoutValidation: input.holdoutValidation, accepted: input.accepted, + engineAcceptAllowed: input.selectionAllowed, + engineProposeAllowed: input.proposeAllowed, + datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods) : undefined, }); } @@ -1138,6 +1180,7 @@ export function buildMethodFollowupPlan(input: { eventClarificationProbes?: readonly DiscriminatingEventProbe[]; evidenceCollectionProbes?: readonly DiscriminatingEventProbe[]; askedProbeKeys?: readonly string[]; + closedCollectFocuses?: readonly Readonly>[]; birthDate?: string | null; accepted?: boolean; candidatesSeparated?: boolean; @@ -1190,7 +1233,11 @@ export function buildMethodFollowupPlan(input: { const healthCovered = hasConfirmedHealth(input.evidence); const occupationCovered = hasConfirmedDomain(input.evidence, "occupation") || input.evidence.some(isOccupationNote) - || declined.has("occupation"); + || declined.has("occupation") + || (careerCovered && occupationCollectFocusClosed([ + ...(input.declinedTopics ?? []), + ...(input.closedCollectFocuses ?? []), + ])); const horaryGiven = hasConfirmedDomain(input.evidence, "horary"); const horaryStatus: MethodCoverageStatus = horaryGiven ? "covered" @@ -1532,19 +1579,36 @@ export function buildMethodFollowupPlan(input: { ...collectionYearFields(familyCollect), }); } else if (!occupationCovered) { - next = sameDomainYearlessCard("occupation") ?? makeFollowup({ - method_id: "occupation", - intent: "collect_method_evidence", - ask_theme: "occupation", - domain: "occupation", - kind_hint: "occupation_note", - user_prompt_hint: collect( - "你长期做什么工作?对照本命第 10 宫和 D10。", - "D1-H10 + D10", - "可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。", - ), - source: "method_coverage", - }); + if (meetsAcceptanceEventQuality(input.evidence)) { + for (const ranked of yearlessDiscriminators) { + const domain = contrastFollowupDomain( + ranked.eventProbe?.domain ?? ranked.contrastProbe?.domain ?? null, + ); + if (declined.has(domain)) continue; + const key = rankedDiscriminatorKey(ranked); + if (key && askedKeys.has(key)) continue; + const candidate = followupFromRanked(ranked); + if (candidate.choice_frame) { + next = candidate; + break; + } + } + } + if (!next) { + next = sameDomainYearlessCard("occupation") ?? makeFollowup({ + method_id: "occupation", + intent: "collect_method_evidence", + ask_theme: "occupation", + domain: "occupation", + kind_hint: "occupation_note", + user_prompt_hint: collect( + "你长期做什么工作?对照本命第 10 宫和 D10。", + "D1-H10 + D10", + "可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。", + ), + source: "method_coverage", + }); + } } else if ( !meetsAcceptanceEventQuality(input.evidence) && !(stage === "d5_refine" && !hasConfirmedDomain(input.evidence, "education") && !declined.has("education")) diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 1d3c9361..117baf4e 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -145,11 +145,13 @@ function natalRecastFromSnapshot( snapshot: Record, receipt: Record | null, table: RectificationHouseTable | null, + wantedMinute: string | null, ): RectificationNatalRecast | null { const raw = record(snapshot.natalRecast) ?? record(snapshot.natal_recast) ?? record(receipt?.natal_recast); const recastTime = table?.time ?? time(raw?.time); const lagna = table?.lagna || (typeof raw?.lagna === "string" ? raw.lagna.trim() : "") || ""; if (!recastTime || !lagna || deniedHouseTable.test(lagna)) return null; + if (wantedMinute && recastTime !== wantedMinute) return null; const meaning = natalRecastMeaning({ time: recastTime, lagna }) ?? (typeof raw?.user_meaning === "string" && raw.user_meaning.trim() && !deniedHouseTable.test(raw.user_meaning) ? raw.user_meaning.trim().slice(0, 220) @@ -169,12 +171,41 @@ export function natalRecastMeaning(table: Pick>, + fallback: RectificationHouseTable | null, +): RectificationHouseTable | null { + if (wanted && tablesByTime[wanted]?.time === wanted) return tablesByTime[wanted]; + if (fallback && wanted && fallback.time === wanted) return fallback; + if (!wanted) return fallback; + return null; +} + +export function publicChartForMinute( + wanted: string | null, + snapshot: Record, + receipt: Record | null, +): { + houseTable: RectificationHouseTable | null; + natalRecast: RectificationNatalRecast | null; +} { + const tablesByTime = houseTablesByTimeFromSnapshot(snapshot, receipt); + const fallback = parseRectificationHouseTable(snapshot.houseTable) + ?? parseRectificationHouseTable(snapshot.house_table) + ?? parseRectificationHouseTable(receipt?.house_table); + const houseTable = houseTableAlignedToMinute(wanted, tablesByTime, fallback); + return { + houseTable, + natalRecast: natalRecastFromSnapshot(snapshot, receipt, houseTable, wanted), + }; +} + export function workingRectificationHouseTable( result: Pick, ): RectificationHouseTable | null { const wanted = result.selectedTime ?? result.representativeTime; - if (wanted && result.houseTablesByTime[wanted]) return result.houseTablesByTime[wanted]; - return result.houseTable; + return houseTableAlignedToMinute(wanted, result.houseTablesByTime, result.houseTable); } function techniqueAuditFromSnapshot( @@ -223,9 +254,11 @@ export function parseRectificationCandidateResult(value: unknown): Rectification const fallbackHouseTable = parseRectificationHouseTable(snapshot.houseTable) ?? parseRectificationHouseTable(snapshot.house_table) ?? parseRectificationHouseTable(receipt?.house_table); - const houseTable = (selectedTime ? houseTablesByTime[selectedTime] : null) - ?? (representativeTime ? houseTablesByTime[representativeTime] : null) - ?? fallbackHouseTable; + const houseTable = houseTableAlignedToMinute( + selectedTime ?? representativeTime, + houseTablesByTime, + fallbackHouseTable, + ); return { resultId: snapshot.resultId, @@ -240,7 +273,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification selectionKind: text(snapshot.selectionKind), houseTable, houseTablesByTime, - natalRecast: natalRecastFromSnapshot(snapshot, receipt, houseTable), + natalRecast: natalRecastFromSnapshot(snapshot, receipt, houseTable, selectedTime ?? representativeTime), techniqueAudit: techniqueAuditFromSnapshot(snapshot, receipt), windowTransitions: windowScan?.transitions ?? parseWindowScanTransitions(snapshot.window_scan), eventDashaLedger: refinement.event_dasha_ledger, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index c375a9aa..72e9fe05 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -48,7 +48,7 @@ import { isDatePrecision, displayDateLabel, evidenceSubjectForDomain, - trainingScoreableGate, + applyOccupationCollectLedgerNorm, } from "@/lib/rectification-agentic/v9/evidence-model"; import { isHoldoutVerificationQuote, @@ -60,7 +60,7 @@ import { readVedastroMinuteSensitiveStatus, sessionOutcomeView, } from "@/lib/rectification-agentic/v9/confirmation-gate"; -import { parseRectificationHouseTable } from "@/lib/rectification-candidate-result"; +import { publicChartForMinute } from "@/lib/rectification-candidate-result"; import { buildMethodFollowupPlan, buildNextUserAction, @@ -240,6 +240,7 @@ export function safeCaseProjection( evidence: parsed.evidence, activeFocus: parsed.conversationSummary.activeFocus, declinedTopics: parsed.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: parsed.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: decision.sessionOutcome, ...catalog, @@ -384,17 +385,28 @@ export function latestResultToolProjection( candidates: latest.candidates, decisionReceipt: latest.decisionReceipt ?? null, }); - const houseTable = parseRectificationHouseTable(latest.decisionReceipt?.house_table); const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null); const contrastPacket = contrastPacketFromLatest(latest); const candidateProjection = authoritativeCandidateProjection(latest); + const overlaid = overlayPublicDecision(latest, decision); + const inference = previousInferenceFromReceipt(latest.decisionReceipt ?? null); + const representativeTime = candidateProjection.consistent + ? (typeof inference?.representative_time === "string" ? inference.representative_time : null) + ?? candidateProjection.representativeTime + ?? (typeof latest.representativeTime === "string" ? latest.representativeTime : null) + : inference + ? null + : (typeof latest.representativeTime === "string" ? latest.representativeTime : null); + const chart = publicChartForMinute( + representativeTime, + latest as unknown as Record, + latest.decisionReceipt ?? null, + ); const base = { result_id: latest.resultId, candidates: candidateProjection.fromInference ? candidateProjection.candidates : latest.candidates, confirmation_allowed: confirmationGate.confirmation_allowed, - representative_time: candidateProjection.fromInference - ? candidateProjection.representativeTime - : latest.representativeTime, + representative_time: representativeTime, selected_time: latest.selectedTime, selection_kind: latest.selectionKind, algorithm_version: latest.algorithmVersion, @@ -417,12 +429,10 @@ export function latestResultToolProjection( unique_minute_claim: false, candidate_range_not_birth_time_truth: true, skill_verification_report: buildSkillVerificationReport({ - representativeTime: candidateProjection.fromInference - ? candidateProjection.representativeTime - : latest.representativeTime, + representativeTime, widthMinutes: width, candidates: candidateProjection.fromInference ? candidateProjection.candidates : latest.candidates, - houseLagna: houseTable?.lagna ?? null, + houseLagna: chart.houseTable?.lagna ?? null, windowScan, eventDashaLedger: refinement.event_dasha_ledger, eventFitRate: refinement.event_fit_rate, @@ -438,21 +448,18 @@ export function latestResultToolProjection( separation: decision.separation, engineResultId: latest.resultId, }), - ...(houseTable ? { house_table: houseTable } : {}), - ...(typeof latest.decisionReceipt?.natal_recast === "object" && latest.decisionReceipt.natal_recast - ? { natal_recast: latest.decisionReceipt.natal_recast } - : {}), + ...(chart.houseTable ? { house_table: chart.houseTable } : {}), + ...(chart.natalRecast ? { natal_recast: chart.natalRecast } : {}), ...(Array.isArray(latest.decisionReceipt?.technique_audit_table) ? { technique_audit_table: latest.decisionReceipt.technique_audit_table } : {}), inference_state: compactInferenceProjection(previousInferenceFromReceipt(latest.decisionReceipt ?? null)), }; - const overlaid = overlayPublicDecision(latest, decision); return { ...base, ...publicDecisionFields(decision), candidates: overlaid.candidates, - representative_time: overlaid.representativeTime ?? overlaid.representative_time ?? base.representative_time, + representative_time: representativeTime, selection_allowed: overlaid.selectionAllowed, confirmation_allowed: confirmationGate.confirmation_allowed, session_outcome_view: sessionOutcomeView(decision.sessionOutcome), @@ -541,6 +548,7 @@ function followupPlanForParsed( evidence: parsed.evidence, activeFocus: parsed.conversationSummary.activeFocus, declinedTopics: parsed.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: parsed.conversationSummary.declinedSkippedTopics, observations, sessionOutcome: decision.sessionOutcome, ...catalog, @@ -764,6 +772,18 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { } }; + const occupationCollectFocusForWrite = async (caseId: string, focusId?: string | null) => { + try { + const dossier = await loadV9CaseDossier(accounting, userId, caseId); + const active = dossier.conversationSummary.activeFocus; + if (!active) return null; + if (focusId && active.id !== focusId) return null; + return active; + } catch { + return null; + } + }; + const persistableReceipt = async (score: V9EngineScoreResult, input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; @@ -1387,6 +1407,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { ? [{ item, index, quote: resolved.quote, quoteStart: resolved.quoteStart, quoteEnd: resolved.quoteEnd }] : [] )); + const occupationFocus = await occupationCollectFocusForWrite(input.caseId, input.focusId ?? null); const result = scoringItems.length === 0 ? { items: [...mismatchResults, ...holdoutResults], @@ -1401,18 +1422,27 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { input.caseId, turnId, input.focusId ?? null, - scoringItems.map(({ item, quote, quoteStart, quoteEnd }) => ({ - quote, - quoteStart, - quoteEnd, - subject: evidenceSubjectForDomain(item.domain, item.subject), - eventKind: item.proposedKind as Parameters[5][number]["eventKind"], - domain: item.domain, - occurredFrom: item.occurredFrom ? normalizeDatePart(item.occurredFrom) : null, - occurredTo: item.occurredTo ? normalizeDatePart(item.occurredTo) : null, - datePrecision: item.datePrecision, - summary: item.summary, - })), + scoringItems.map(({ item, quote, quoteStart, quoteEnd }) => { + const [normalized] = applyOccupationCollectLedgerNorm(occupationFocus, [{ + domain: item.domain, + eventKind: item.proposedKind as Parameters[5][number]["eventKind"], + datePrecision: item.datePrecision, + occurredFrom: item.occurredFrom ? normalizeDatePart(item.occurredFrom) : null, + occurredTo: item.occurredTo ? normalizeDatePart(item.occurredTo) : null, + }]); + return { + quote, + quoteStart, + quoteEnd, + subject: evidenceSubjectForDomain(normalized?.domain ?? item.domain, item.subject), + eventKind: (normalized?.eventKind ?? item.proposedKind) as Parameters[5][number]["eventKind"], + domain: normalized?.domain ?? item.domain, + occurredFrom: normalized?.occurredFrom ?? (item.occurredFrom ? normalizeDatePart(item.occurredFrom) : null), + occurredTo: normalized?.occurredTo ?? (item.occurredTo ? normalizeDatePart(item.occurredTo) : null), + datePrecision: normalized?.datePrecision ?? item.datePrecision, + summary: item.summary, + }; + }), ).then((recorded) => ({ items: [ ...recorded.items.map((item, offset) => ({ @@ -1518,15 +1548,23 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { try { const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null; const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null; + const occupationFocus = await occupationCollectFocusForWrite(input.caseId); + const [normalized] = applyOccupationCollectLedgerNorm(occupationFocus, [{ + domain: input.domain, + eventKind: input.proposedKind, + datePrecision: input.datePrecision, + occurredFrom, + occurredTo, + }]); const result = await proposeV9Evidence(accounting, userId, input.caseId, { sourceTurnId: turnId, quote: input.quote, - subject: evidenceSubjectForDomain(input.domain, input.subject), - eventKind: input.proposedKind, - domain: input.domain, - occurredFrom, - occurredTo, - datePrecision: input.datePrecision, + subject: evidenceSubjectForDomain(normalized?.domain ?? input.domain, input.subject), + eventKind: normalized?.eventKind ?? input.proposedKind, + domain: normalized?.domain ?? input.domain, + occurredFrom: normalized?.occurredFrom ?? occurredFrom, + occurredTo: normalized?.occurredTo ?? occurredTo, + datePrecision: normalized?.datePrecision ?? input.datePrecision, summary: input.summary, }); await receipt("rectification-propose-evidence", "evidence.proposed", "completed", { diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index a0f6cdec..c3a51965 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, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); 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-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 6c1be8a7..c6ecf66e 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -351,14 +351,19 @@ function revision5Dossier( }; } -function planFrom(dossier: DecisionDossier) { +function planFrom( + dossier: DecisionDossier, + extra: Partial[0]> = {}, +) { const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); return buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, + ...extra, }); } @@ -452,9 +457,14 @@ test("answering C on the D12 card declines the family focus and scores the no br declinedTopics: [{ target_domain: "family", status: "declined" }], })); assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered"); - assert.equal(next.next_followup?.domain, "occupation"); - assert.equal(next.next_followup?.intent, "collect_method_evidence"); assert.notEqual(next.next_followup?.domain, "family"); + if (next.next_followup?.choice_frame) { + assert.equal(next.next_followup.intent, "distinguish_candidates"); + assert.match(next.next_followup.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); + } else { + assert.equal(next.next_followup?.domain, "occupation"); + assert.equal(next.next_followup?.intent, "collect_method_evidence"); + } }); test("persistServerOwnedFocus writes family as target_domain so a no answer becomes declined coverage", async () => { @@ -506,9 +516,10 @@ test("occupation collect denial declines the focus and advances coverage to hora answer_class: "unsure", }), false); + const askedYearless = [D24, D12, D7, D4, D5].map((item) => item.semantic_key); const occupationPlan = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], - })); + }), { askedProbeKeys: askedYearless }); assert.equal(occupationPlan.next_followup?.domain, "occupation"); assert.equal(occupationPlan.next_followup?.intent, "collect_method_evidence"); assert.equal(occupationPlan.next_followup?.choice_frame, null); @@ -589,7 +600,7 @@ test("occupation collect denial declines the focus and advances coverage to hora { target_domain: "family", status: "declined" }, { target_domain: "occupation", status: "declined" }, ], - })); + }), { askedProbeKeys: askedYearless }); assert.equal(horary.next_followup?.domain, "horary"); }); @@ -643,7 +654,7 @@ test("free-text turns persist the next followup so current_question is not null" expectedAnswerSchema: setFocus?.args.p_expected_answer_schema as Record, }); assert.notEqual(question, null); - assert.equal(question?.kind, "collect_spoken"); + assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice"); }); test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => { diff --git a/frontend/tests/rectification-discriminator-followup-consistency.test.ts b/frontend/tests/rectification-discriminator-followup-consistency.test.ts index 8f98b5b8..0e6f2fde 100644 --- a/frontend/tests/rectification-discriminator-followup-consistency.test.ts +++ b/frontend/tests/rectification-discriminator-followup-consistency.test.ts @@ -468,7 +468,7 @@ test("agent message path no longer returns the discriminator dead-end copy", () route.indexOf("const requestTime"), ); assert.doesNotMatch(fastPath, /目前没有可继续区分/); - assert.match(route, /当前几个候选已经构成可信区间/); + assert.match(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/); assert.match(fastPath, /!plan\.next_followup/); assert.ok(fastPath.includes("ask_candidate_discriminator")); diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts new file mode 100644 index 00000000..e9f6bfb0 --- /dev/null +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; +import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + parseRectificationCandidateResult, + workingRectificationHouseTable, +} from "../src/lib/rectification-candidate-result.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; +import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts"; + +const STYLE_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 TIED = [ + { time: "04:47", score: 12 }, + { time: "04:51", score: 12 }, + { time: "04:53", score: 11 }, + { time: "04:59", score: 11 }, + { time: "05:00", score: 11 }, + { time: "05:07", score: 11 }, + { time: "05:12", score: 11 }, + { time: "05:14", score: 8 }, + { time: "05:15", score: 7 }, +]; + +function dated( + id: string, + domain: string, + eventKind: string, + occurredFrom: string, + extra: { + occurredTo?: string | null; + datePrecision?: "year" | "month" | "day" | "range" | "unknown"; + status?: "confirmed" | "draft"; + } = {}, +) { + return { + id, + status: extra.status ?? ("confirmed" as const), + domain, + datePrecision: extra.datePrecision ?? ("month" as const), + occurredFrom, + occurredTo: extra.occurredTo ?? null, + eventKind, + }; +} + +/** 19-row ledger from the stalled case: 15 scoreable, 4 domains; occupation never recorded. */ +const CASE_EVIDENCE = [ + dated("e-edu-start", "education", "education_start", "2016-09-01"), + dated("e-edu-complete", "education", "education_completion", "2020-06-01"), + dated("e-edu-interrupt", "education", "education_interruption", "2021-01-01", { + occurredTo: "2023-07-01", + datePrecision: "range", + }), + dated("e-edu-change", "education", "education_change", "2023-01-01", { datePrecision: "year" }), + dated("e-career-entry-2020", "career", "career_entry", "2020-04-01"), + dated("e-career-exit-2020", "career", "career_exit", "2020-10-01"), + dated("e-career-entry-2024", "career", "career_entry", "2024-04-07", { datePrecision: "day" }), + dated("e-career-exit-2026", "career", "career_exit", "2026-08-11", { datePrecision: "day" }), + dated("e-career-change-2022", "career", "career_change", "2022-12-01"), + dated("e-career-change-2024", "career", "career_change", "2024-01-01", { datePrecision: "year" }), + dated("e-career-range", "career", "career_change", "2020-04-01", { + occurredTo: "2020-10-01", + datePrecision: "range", + }), + dated("e-career-draft-a", "career", "career_change", "", { + datePrecision: "unknown", + status: "draft", + occurredTo: null, + }), + dated("e-career-draft-b", "career", "career_entry", "", { + datePrecision: "unknown", + status: "draft", + occurredTo: null, + }), + dated("e-rel-2024-05", "relationship", "relationship_change", "2024-05-01"), + dated("e-rel-start", "relationship", "relationship_start", "2024-06-01"), + dated("e-rel-end", "relationship", "relationship_end", "2024-08-08", { datePrecision: "day" }), + dated("e-reloc", "relocation", "home_change", "2022-10-01"), + dated("e-family", "family", "family_event", "2016-05-01"), +].map((item) => ({ + ...item, + occurredFrom: item.occurredFrom || null, +})); + +const OCCUPATION_FOCUS = { + intent: "collect_method_evidence" as const, + targetDomain: "occupation", + targetKind: "occupation_note", + questionId: "collect:occupation:collect_method_evidence", +}; + +function yearlessProbe(layer: string, gain: number, domain: string) { + return { + probeId: `contrast:varga.${layer}.04:47/05:00`, + candidateSetVersion: "04:47-05:15", + question: `当前几个候选在 ${layer} 上还分得开。`, + expectedOutcomes: [ + { outcomeId: "yes" as const, supportsCandidateIds: ["04:47"], conflictsCandidateIds: ["05:00"] }, + { outcomeId: "no" as const, supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["04:47"] }, + ], + candidateSplitHash: `varga.${layer}.04:47/05:00`, + informationGain: gain, + sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }], + domain, + year: null as number | null, + semanticKey: `varga.${layer}.04:47/05:00`, + choiceKind: "existence" as const, + styleOptions: STYLE_OPTIONS.map((item) => ({ + label: item.label, + answerClass: item.answer_class, + })), + }; +} + +const YEARLESS_PACKET = { + candidateSetVersion: "04:47-05:15", + vargaDifferences: [] as const, + probes: [ + yearlessProbe("d24", 2.5, "education"), + yearlessProbe("d7", 1.22, "family"), + yearlessProbe("d4", 0.99, "relocation"), + yearlessProbe("d5", 0.5, "education"), + ], +}; + +function occupationPlan( + extra: Partial[0]> = {}, +) { + return buildMethodFollowupPlan({ + evidence: CASE_EVIDENCE, + contrastPacket: YEARLESS_PACKET, + candidatesSeparated: false, + ...extra, + }); +} + +function twelveHouses(sign: string, occupant?: string) { + return Array.from({ length: 12 }, (_, index) => ({ + house: index + 1, + sign, + occupants: index === 0 && occupant ? [occupant] : [], + })); +} + +const CANDIDATE_IDS = [ + "88888888-8888-4888-8888-888888888881", + "88888888-8888-4888-8888-888888888882", +] as const; + +test("skill version stays 10.0.13 for the occupation-coverage exit fix", () => { + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13"); +}); + +test("nineteen-row ledger opens the training gate with four scoreable domains", () => { + const gate = trainingScoreableGate(CASE_EVIDENCE); + assert.equal(gate.open, true); + assert.ok(gate.trainingCount >= 3); + assert.ok(gate.trainingDomainCount >= 2); + assert.ok(CASE_EVIDENCE.length >= 15); +}); + +test("career answers under an occupation collect focus do not cover occupation before ledger norm", () => { + const plan = occupationPlan(); + assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); + assert.equal( + CASE_EVIDENCE.some((item) => item.domain === "occupation" && item.eventKind === "occupation_note"), + false, + ); +}); + +test("answering occupation collect remaps a career-domain job description onto occupation_note", async () => { + const { applyOccupationCollectLedgerNorm } = await import( + "../src/lib/rectification-agentic/v9/evidence-model.ts" + ); + const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{ + domain: "career", + eventKind: "career_entry" as const, + datePrecision: "unknown" as const, + occurredFrom: null, + occurredTo: null, + summary: "长期做有机合成", + quote: "有机合成", + subject: "self" as const, + }]); + assert.equal(remapped[0]?.domain, "occupation"); + assert.equal(remapped[0]?.eventKind, "occupation_note"); + const plan = occupationPlan({ + evidence: [ + ...CASE_EVIDENCE, + { + status: "confirmed" as const, + domain: remapped[0]!.domain, + datePrecision: remapped[0]!.datePrecision, + occurredFrom: remapped[0]!.occurredFrom, + occurredTo: remapped[0]!.occurredTo, + eventKind: remapped[0]!.eventKind, + summary: remapped[0]!.summary, + }, + ], + contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, + }); + assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); + assert.notEqual(plan.next_followup?.method_id, "occupation"); + assert.notEqual(plan.next_followup?.ask_theme, "occupation"); +}); + +test("occupation coverage fallback uses a closed occupation collect focus plus career evidence", () => { + const uncovered = occupationPlan({ + contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, + }); + assert.equal(uncovered.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); + const covered = occupationPlan({ + contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, + closedCollectFocuses: [{ + questionId: OCCUPATION_FOCUS.questionId, + targetDomain: "occupation", + intent: "collect_method_evidence", + status: "resolved", + }], + }); + assert.equal(covered.methods.find((item) => item.method_id === "occupation")?.status, "covered"); + assert.notEqual(covered.next_followup?.method_id, "occupation"); +}); + +test("career evidence alone still does not cover occupation", () => { + const plan = occupationPlan({ + contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, + }); + assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); + assert.equal(plan.next_followup?.method_id, "occupation"); +}); + +test("training gate open and coverage incomplete can select a yearless varga card", () => { + const plan = occupationPlan(); + assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.ok(plan.next_followup?.choice_frame); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); + assert.notEqual(plan.next_followup?.method_id, "occupation"); + assert.notEqual(plan.next_followup?.source, "method_coverage"); +}); + +test("training gate closed still withholds yearless varga cards", () => { + const short = [ + dated("e-edu", "education", "education_start", "2016-09-01"), + dated("e-career", "career", "career_entry", "2020-04-01"), + ]; + assert.equal(trainingScoreableGate(short).open, false); + const plan = buildMethodFollowupPlan({ + evidence: short, + contrastPacket: YEARLESS_PACKET, + candidatesSeparated: false, + }); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); + assert.notEqual(plan.next_followup?.intent, "distinguish_candidates"); +}); + +test("training complete with no renderable distinguish card allows offering a range without adopt", () => { + const blocked = decideRectification({ + methodCoverageAll: false, + trainingGateOpen: true, + candidateScores: TIED, + discriminatorProbe: null, + engineAcceptAllowed: true, + engineProposeAllowed: true, + }); + assert.equal(blocked.canOfferRange, true); + assert.equal(blocked.canAdopt, false); + assert.equal(blocked.canConfirmExactMinute, false); + assert.equal(blocked.nextAction, "offer_provisional_range"); + assert.equal(blocked.sessionOutcome, "provisional_range"); + const stillClosed = decideRectification({ + methodCoverageAll: false, + trainingGateOpen: false, + candidateScores: TIED, + discriminatorProbe: null, + engineAcceptAllowed: true, + engineProposeAllowed: true, + }); + assert.equal(stillClosed.canOfferRange, false); + assert.equal(stillClosed.canAdopt, false); + assert.equal(stillClosed.nextAction, "ask_fact_collection"); +}); + +test("decideFromDossier offers a range when training is complete and the ask layer has no card", () => { + const decision = decideFromDossier({ + evidence: CASE_EVIDENCE, + conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + selectionAllowed: true, + confirmationAllowed: false, + candidates: TIED.map((item, index) => ({ + candidateId: `88888888-8888-4888-8888-88888888888${index}`, + time: item.time, + rank: index + 1, + relativeSupport: item.score, + })), + representativeTime: "04:47", + decisionReceipt: { + accept_allowed: true, + propose_allowed: true, + selection_allowed: true, + }, + }, + case: { acceptedTime: null }, + }); + assert.equal(decision.canOfferRange, true); + assert.equal(decision.canAdopt, false); + assert.equal(decision.canConfirmExactMinute, false); +}); + +test("agent route narrates the binary range exit 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, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); + const fastPath = route.slice( + route.indexOf('if (action === "message")'), + route.indexOf("const requestTime"), + ); + assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION|isNonConvergingRangeOffer|canOfferRange/); + assert.doesNotMatch(fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); +}); + +test("public house_table and natal_recast follow the representative minute", () => { + const matched = parseRectificationCandidateResult({ + resultId: "11111111-1111-4111-8111-111111111111", + candidates: [ + { candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:47", relativeSupport: 12, tiedMinuteCount: 1 }, + { candidateId: CANDIDATE_IDS[1], rank: 2, time: "05:00", relativeSupport: 11, tiedMinuteCount: 1 }, + ], + overallConfidence: "low", + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "04:47", + selectedTime: null, + decisionReceipt: { + house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, + house_tables_by_time: { + "04:47": { time: "04:47", lagna: "金牛座", houses: twelveHouses("金牛座", "太阳") }, + "05:00": { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, + }, + natal_recast: { + time: "05:00", + lagna: "双子座", + user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。", + unique_minute_claim: false, + confirmation_allowed: false, + }, + }, + }); + assert.equal(matched?.representativeTime, "04:47"); + assert.equal(matched?.houseTable?.time, "04:47"); + assert.equal(matched?.houseTable?.lagna, "金牛座"); + assert.equal(matched?.natalRecast?.time, "04:47"); + assert.equal(workingRectificationHouseTable(matched!)?.time, "04:47"); +}); + +test("a mismatched receipt house_table is not emitted with another representative minute", () => { + const mismatched = parseRectificationCandidateResult({ + resultId: "11111111-1111-4111-8111-111111111111", + candidates: [ + { candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:47", relativeSupport: 12, tiedMinuteCount: 1 }, + { candidateId: CANDIDATE_IDS[1], rank: 2, time: "05:00", relativeSupport: 11, tiedMinuteCount: 1 }, + ], + overallConfidence: "low", + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "04:47", + selectedTime: null, + decisionReceipt: { + house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, + natal_recast: { + time: "05:00", + lagna: "双子座", + user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。", + unique_minute_claim: false, + confirmation_allowed: false, + }, + }, + }); + assert.equal(mismatched?.representativeTime, "04:47"); + assert.notEqual(mismatched?.houseTable?.time, "05:00"); + assert.equal(mismatched?.houseTable, null); + assert.notEqual(mismatched?.natalRecast?.time, "05:00"); +}); + +test("tool projection does not pair a 05:00 house table with a 04:47 representative time", () => { + const decision = decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + candidateScores: TIED, + discriminatorProbe: null, + }); + const projection = latestResultToolProjection({ + resultId: "55555555-5555-4555-8555-555555555555", + candidates: TIED.map((item, index) => ({ + candidateId: `88888888-8888-4888-8888-88888888888${index}`, + time: item.time, + rank: index + 1, + relativeSupport: item.score, + tiedMinuteCount: 1, + })), + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "04:47", + selectedTime: null, + selectionKind: null, + algorithmVersion: "test", + decisionReceipt: { + house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, + natal_recast: { + time: "05:00", + lagna: "双子座", + user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。", + unique_minute_claim: false, + confirmation_allowed: false, + }, + }, + }, decision); + assert.notEqual(projection.house_table && (projection.house_table as { time?: string }).time, "05:00"); + if (projection.representative_time === "04:47" && projection.house_table) { + assert.equal((projection.house_table as { time?: string }).time, "04:47"); + } +}); + +test("collect focus is still not a renderable choice card", () => { + assert.equal(isRenderableChoiceOpenQuestion({ + question_id: OCCUPATION_FOCUS.questionId, + prompt: "你长期做什么工作?", + status: "already_open", + kind: "collect_spoken", + intent: "collect_method_evidence", + domain: "occupation", + focus_id: "11111111-1111-4111-8111-111111111111", + probe_id: null, + }), false); +}); diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index d7f8cb17..55334e7c 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -138,7 +138,7 @@ 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(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/); assert.match(fastPath, /!plan\.next_followup/); assert.doesNotMatch(route, /classified\.answer_class!/);