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!/);