/** * Eight-method follow-up routing for birth-time rectification. * * Web used to round-robin SQL missing_evidence_categories (relocation / * health / finance). Local skill asks by method layer. This plan is the * server's next question. It still only produces candidates, never a * confirmed unique minute. * * Policy map (classic eight): * 1. Dasha + dated events — any confirmed dated event * 2. D9 relationship — confirmed relationship evidence; D9 type table is a * rectification method, not a fate promise * 3. D10 career — confirmed dated career evidence; same event also scores D1 10th house * 4. Relatives — confirmed family evidence (D12 + D7 + D3) * 5. Appearance / constitution — skipped; never asked * 6. Birthmarks / scars — skipped; never asked * 7. Occupation / 10th house — separate from dated career events; D10 type table allowed. * A draft/confirmed occupation_note without a date still covers this layer. * 8. Horary — ask once for the first question time; recast if given; never blocks cards * * Relocation / finance / health are not adopt gates, but after family is * covered or declined they are still asked while the remaining minutes * need distinguishing (BUG-546). Occupation is a method-layer note, not an * adopt gate: dated dasha/D9/D10/relatives coverage plus the engine ceiling * can mark representative cards as available while occupation remains * uncovered. Persist still writes remaining S1 pool collect and occupation * before the adopt early-exit. Yearless contrast is not rewritten into a * domain-wheel collect after the training gate (BUG-648). Career evidence plus a closed occupation * collect focus still covers the occupation method without depending on * the model picking domain=occupation. Appearance and marks are * skipped_by_policy. Horary does not block offering time cards. * 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 * probes skip a year already in the ledger, not the whole domain. * A yes/weak_yes discriminator answer covers that domain's spoken * collect without writing the ledger. no/unsure does not. * Contrast-packet ranking must use the same year rule: a dated * unstructured probe stays eligible when the domain already has a * different year. Information gain is recomputed on the current * active candidates; a probe that no longer splits that set is * dropped as no_split_among_active. * Scoring A/B/C/D reverse-inference needs the engine year/month. * Yearless varga splits do not borrow a ledger year. Pick the next * dated discriminator, or collect a dated event in that domain. * Remaining dated dasha distinguish probes are asked first. Yearless * D9/D10 style and nakshatra_boundary stay out of the dated pool and * never occupy ask_candidate_discriminator (BUG-629, BUG-651). When * the dated pool is empty after the training gate, deliver the range. * If holdout is already reserved but training is still short, * keep collecting a dated event instead of discriminating. * Once blocking methods are covered, move into candidate discrimination. * Coverage complete never means adopt. Horary does not block cards. * A/B/C/D choice frames attach only when candidates already diverge * (event probes, precision stage, varga observation, nakshatra, or holdout). * An already-open distinguish card yields if the live catalog winner is a * different probe. Yearless D24 event_quality yields to a dated dasha probe; * do not stamp a ledger year onto a yearless scoring card. An open varga * 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 contrast is renderable only when * choice_kind is varga_style and scoring options carry signs. Other * remaining-layer probes stay in the packet but are dropped as * yearless_ungrounded_contrast. Uncovered relationship, career, and * family still ask a same-domain signed style card or dated spoken * collect first. Occupation collect must not block signed D9/D10 cards * once training events already pass. Do not re-widen yearless existence * or quality cards to keep the interview moving. */ import { buildChoiceFrame, isPersistedFocusId, parseAgentChoiceCopy, preferConcreteChoicePrompt, mergeChoiceCard, choiceCardFromPersistedVerifyCopy, serverOwnedChoiceCopy, type RectificationChoiceCard, type RectificationChoiceFrame, } from "./choice-card.ts"; import type { ProbeExplainCandidate } from "./probe-explain.ts"; import { overlayChoicePromptFromSpoken } from "./turn-narration.ts"; import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY, collectQuestionForDomain, withFirstDatedCollectInvite, } from "../user-copy.ts"; import { collectionQuestionPool, isInviteCollectTopic, type CollectionPoolItem, } from "./collection-question-pool.ts"; export { GENERIC_COLLECT_QUESTION }; import { RECTIFICATION_TERMINATION_COPY, decideRectification, type HoldoutValidationStatus, } from "../core/rectification-decision.ts"; import type { ConflictProbe } from "../core/types.ts"; import { datedDomainsFromEvidence, isStructuredDiscriminator, isSameCandidateSplit, mentionedVargaKeysFromLedgerEvidence, vargaLayerCovered, vargaLayerFromSemanticKey, withCompletedContrastOptions, type CandidateContrastPacket, type CandidateDiscriminatorProbe, } from "../core/candidate-contrast-packet.ts"; import { candidateIdsFromProbe, isValidDistinguishProbe } from "../core/distinguish-contract.ts"; import { completeStyleOptions, informationGainAmongActive, isRenderableProbe, rankDiscriminatorScore, askedKeysForSameYearDedup, isEventQualityProbe, sameYearProbeAsked, SEMANTIC_YEAR_KEY, type DroppedProbe, type ProbeStyleOption, } from "./probe-question-contract.ts"; import type { SessionOutcomeKind } from "./confirmation-gate.ts"; import { meetsAcceptanceEventQuality, trainingScoreableGate } from "./evidence-model"; import type { DiscriminatingEventProbe, EventProbeDomain, EventProbeStyleOption, OosBlindPrompt, PrecisionStageId, } from "./refinement-packet"; import { EVENT_PROBE_DOMAINS } from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; import { probeBelowAdultFloor } from "./adult-floor.ts"; import { BLOCK_CHOICE_INTENT, BLOCK_CHOICE_KIND, WIDEN_WINDOW_KIND, WIDEN_WINDOW_INTENT, blockPeriodsForChoice, buildBlockChoiceFrame, type BlockScanBlock, type BlockScanPayload, type RectificationCaseStage, } from "./block-scan.ts"; import { buildWidenWindowFrame, widenWindowsForChoice, } from "./window-widen.ts"; import { DATE_RELIABILITY_PROMPT, pendingDateReliabilityEvidence } from "./date-reliability.ts"; export { isEventQualityProbe, sameYearProbeAsked }; export const METHOD_FOLLOWUP_IDS = [ "dasha_events", "d9_relationship", "d10_career", "relatives", "appearance", "marks", "occupation", "horary", ] as const; export type MethodFollowupId = (typeof METHOD_FOLLOWUP_IDS)[number]; export type MethodCoverageStatus = "covered" | "uncovered" | "skipped_by_policy"; export type MethodCoverage = Readonly<{ method_id: MethodFollowupId; status: MethodCoverageStatus; }>; export type MethodFollowup = Readonly<{ method_id: "dasha_events" | "d9_relationship" | "d10_career" | "d4_home" | "d5_education" | "relatives" | "d2_finance" | "d30_health" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_boundary" | "oos_blind" | "reverse_verify" | "holdout_validation"; intent: string; ask_theme: "dated_event" | "relationship_style" | "career_style" | "home_change" | "education_style" | "family_event" | "finance_change" | "health_pressure" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_trait" | "oos_blind" | "holdout" | "birth_block" | "widen_window"; domain: string | null; kind_hint: string | null; user_prompt_hint: string; must_not_label: boolean; choice_frame: RectificationChoiceFrame | null; source: "active_focus" | "method_coverage" | "varga_observation" | "precision_stage" | "nakshatra_boundary" | "oos_blind" | "reverse_verify" | "event_probe" | "block_scan" | "window_widen"; information_gain?: number; semantic_key?: string; candidate_split_hash?: string; probe_year?: number; year_label?: string; probe_month?: number; choice_kind?: "existence" | "varga_style" | "event_quality" | "block_choice" | "widen_window"; candidate_ids?: readonly string[]; expected_outcomes?: DiscriminatingEventProbe["expected_outcomes"]; style_options?: readonly Readonly<{ label: string; answer_class: string; sign?: string; }>[]; selection_score?: number; probe_id?: string; collect_retry?: boolean; invite_more_once?: boolean; spoken_prompt?: string; collection_key?: string; block_periods?: Readonly>; widen_windows?: Readonly>; date_reliability_evidence_id?: string; }>; export type MethodFollowupPlan = Readonly<{ methods: readonly MethodCoverage[]; next_followup: MethodFollowup | null; deferred_followup: MethodFollowup | null; session_outcome: SessionOutcomeKind; stop_domain_rotation: true; do_not_poll: readonly []; not_in_rotation: readonly ["relocation"]; dropped_probes: readonly DroppedProbe[]; }>; export type MethodFollowupEvidence = Readonly<{ status: string; domain: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null; eventKind?: string | null; id?: string; summary?: string | null; dateReliability?: string | null; sourceTurnId?: string | null; }>; export type MethodFollowupFocus = Readonly<{ id?: string; questionId?: string; intent: string; targetDomain: string | null; targetKind: string | null; expectedAnswerSchema?: Readonly> | null; }>; const DO_NOT_POLL = [] as const; const NOT_IN_ROTATION = ["relocation"] as const; const BLOCKING_COVERAGE_IDS = new Set([ "dasha_events", "d9_relationship", "d10_career", "relatives", ]); function isConfirmedDated( item: Pick, ): boolean { return item.status === "confirmed" && item.datePrecision !== "unknown" && Boolean(item.occurredFrom || item.occurredTo); } function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: string): boolean { return evidence.some((item) => item.status === "confirmed" && item.domain === domain); } export type AnsweredProbeCoverageRow = Readonly<{ semantic_key: string; probe_id?: string; answer_class: string; classified_from?: string; }>; function domainForAnsweredProbe( answer: AnsweredProbeCoverageRow, eventProbes: readonly DiscriminatingEventProbe[], ): string | null { const keys = new Set( [answer.semantic_key, answer.probe_id].filter((key): key is string => Boolean(key?.trim())), ); if (keys.size === 0) return null; for (const probe of eventProbes) { const semantic = probe.semantic_key?.trim() ?? ""; if (!semantic || !keys.has(semantic)) continue; const domain = probe.domain?.trim(); if (domain) return domain; } return null; } /** Domains where a discriminator was answered yes/weak_yes. Lookup is by probe identity, not key prefix. */ export function domainsAnsweredYes( answeredProbes: readonly AnsweredProbeCoverageRow[] | undefined, eventProbes: readonly DiscriminatingEventProbe[] | undefined, ): Set { const domains = new Set(); if (!answeredProbes?.length) return domains; const catalog = eventProbes ?? []; for (const answer of answeredProbes) { if (answer.answer_class !== "yes" && answer.answer_class !== "weak_yes") continue; if (answer.classified_from && answer.classified_from !== "choice") continue; const domain = domainForAnsweredProbe(answer, catalog); if (domain) domains.add(domain); } return domains; } function evidenceYear(item: MethodFollowupEvidence): number | null { const raw = item.occurredFrom || item.occurredTo; if (!raw || raw.length < 4 || !/^\d{4}/.test(raw)) return null; const year = Number(raw.slice(0, 4)); return year >= 1900 && year <= 2100 ? year : null; } /** 高考与当年 9 月入学通常是同一学年;感情/事业/搬家的邻近年也常是同一段经历。 */ const EXISTENCE_NEARBY_YEARS: Readonly> = { education: 1, relationship: 1, career: 1, relocation: 1, }; const RECORDED_KIND_LABEL: Readonly> = { education_start: "入学", education_completion: "毕业", education_interruption: "学业中断", education_change: "转学或学业变化", education_milestone: "学业节点", relationship_start: "感情开始", relationship_commitment: "关系确认", relationship_separation: "感情分开", relationship_end: "感情结束", career_entry: "入职", career_change: "事业变化", promotion: "升职", relocation: "搬家", home_change: "住处变化", }; function existenceNearbyYears(domain: string): number { return EXISTENCE_NEARBY_YEARS[domain] ?? 0; } export function existenceProbeAsked( askedKeys: ReadonlySet | readonly string[], domain: string, year: number, ): boolean { if (!domain || !year) return false; if (sameYearProbeAsked(askedKeys, domain, year)) return true; const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys); const nearby = existenceNearbyYears(domain); if (nearby <= 0) return false; for (const key of asked) { const match = key.match(SEMANTIC_YEAR_KEY); if (!match || match[1] !== domain) continue; const askedYear = Number(match[2]); if (Number.isInteger(askedYear) && Math.abs(askedYear - year) <= nearby) return true; } return false; } export function datedLedgerAnchor( evidence: readonly MethodFollowupEvidence[] | undefined, domain: string | null | undefined, ): { year: number; month: number | null; label: string } | null { if (!domain) return null; let best: { year: number; month: number | null; label: string } | null = null; for (const item of evidence ?? []) { if (!isConfirmedDated(item) || item.domain !== domain) continue; const year = evidenceYear(item); if (year === null) continue; const raw = item.occurredFrom || item.occurredTo || ""; const month = raw.length >= 7 && raw[4] === "-" ? Number(raw.slice(5, 7)) : null; const label = month && Number.isInteger(month) && month >= 1 && month <= 12 ? `${year} 年 ${month} 月` : `${year} 年`; if (!best || (month && !best.month)) { best = { year, month: month && Number.isInteger(month) && month >= 1 && month <= 12 ? month : null, label }; } } return best; } function nearbyLedgerHint( evidence: readonly MethodFollowupEvidence[] | undefined, domain: string | null | undefined, year: number | null | undefined, month?: number | null, ): string { if (!domain || !year || year <= 0) return ""; const probeIndex = month && month >= 1 && month <= 12 ? year * 12 + month : null; let best: { label: string; family: string; delta: number } | null = null; for (const item of evidence ?? []) { if (!isConfirmedDated(item) || item.domain === domain) continue; const otherYear = evidenceYear(item); if (otherYear === null) continue; const raw = item.occurredFrom || item.occurredTo || ""; const otherMonth = raw.length >= 7 && raw[4] === "-" ? Number(raw.slice(5, 7)) : null; const otherIndex = otherMonth && otherMonth >= 1 && otherMonth <= 12 ? otherYear * 12 + otherMonth : null; let delta = 99; if (probeIndex != null && otherIndex != null) { delta = Math.abs(probeIndex - otherIndex); if (delta > 2) continue; } else if (otherYear !== year) { continue; } else { delta = 2; } const family = EXISTENCE_EVENT_FAMILY[item.domain]; if (!family) continue; const label = otherIndex ? `${otherYear} 年 ${otherMonth} 月` : `${otherYear} 年`; if (!best || delta < best.delta) best = { label, family, delta }; } return best ? `账本里 ${best.label} 有${best.family};题干先提那件事再问。` : ""; } function probeYearAlreadyCovered( evidence: readonly MethodFollowupEvidence[], domain: string, year: number, ): boolean { const nearby = existenceNearbyYears(domain); return evidence.some((item) => { if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") { return false; } if (item.domain !== domain) return false; const itemYear = evidenceYear(item); if (itemYear === null) return false; return Math.abs(itemYear - year) <= nearby; }); } function recordedKindYearHint(evidence: readonly MethodFollowupEvidence[]): string { const labels: string[] = []; const seen = new Set(); for (const item of evidence) { if (!isConfirmedDated(item)) continue; const year = evidenceYear(item); if (year === null) continue; const kindLabel = RECORDED_KIND_LABEL[item.eventKind ?? ""] ?? null; if (!kindLabel) continue; const token = `${year}:${kindLabel}`; if (seen.has(token)) continue; seen.add(token); labels.push(`${year} 年${kindLabel}`); } if (labels.length === 0) return ""; return `已记下 ${labels.join("、")}。不要再问这些事发生在哪一年。`; } function isOccupationNote(item: MethodFollowupEvidence): boolean { if (item.domain !== "occupation") return false; if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") { return false; } return !item.eventKind || item.eventKind === "occupation_note"; } export function blockingMethodsCovered(methods: readonly MethodCoverage[]): boolean { return !methods.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"); } export function datedMethodCollectOpen( methods: readonly MethodCoverage[], evidence?: readonly MethodFollowupEvidence[], declinedTopics?: readonly Readonly>[], ): boolean { if (evidence) { if (trainingScoreableGate(evidence).open) return false; return nextCollectionFollowup(evidence, declinedTopics ?? []) != null; } 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"); } function declinedHealth(declined: ReadonlySet): boolean { return declined.has("health") || declined.has("health_pressure"); } function topicDomain(topic: Readonly>): string | null { return typeof topic.target_domain === "string" ? topic.target_domain : typeof topic.targetDomain === "string" ? topic.targetDomain : null; } function topicQuestionId(topic: Readonly>): string { return typeof topic.questionId === "string" ? topic.questionId : typeof topic.question_id === "string" ? topic.question_id : ""; } function isOpeningOtherCollectTopic(topic: Readonly>): boolean { return topicDomain(topic) === "other" && topicQuestionId(topic).startsWith("collect:other:"); } function topicIntent(topic: Readonly>): string { return typeof topic.intent === "string" ? topic.intent : ""; } function topicStatus(topic: Readonly>): string { return typeof topic.status === "string" ? topic.status : ""; } function declinedDomains( topics: readonly Readonly>[], options: { includeSkipped?: boolean } = {}, ): Set { const includeSkipped = options.includeSkipped !== false; const domains = new Set(); for (const topic of topics) { if (isOpeningOtherCollectTopic(topic) || isInviteCollectTopic(topic)) continue; const intent = topicIntent(topic); // Distinguish-card "no" is an answer, not a domain refusal (BUG-520). // Missing intent stays collect for legacy declined_skipped rows. if (intent === "distinguish_candidates") continue; if (intent && intent !== "collect_method_evidence") continue; if (!includeSkipped && topicStatus(topic) === "skipped") continue; const domain = topicDomain(topic); if (domain) domains.add(domain); } return domains; } export function declinedCollectDomains( topics: readonly Readonly>[] | undefined, ): Set { return declinedDomains(topics ?? []); } function domainCollectFocusAsked( topics: readonly Readonly>[], domain: string, ): boolean { return topics.some((topic) => { const status = typeof topic.status === "string" ? topic.status : ""; if (status === "active" || status === "declined") return false; if (isOpeningOtherCollectTopic(topic)) return false; const target = topicDomain(topic); const questionId = topicQuestionId(topic); const intent = typeof topic.intent === "string" ? topic.intent : ""; if (questionId.startsWith(`collect:${domain}:`)) return true; return target === domain && (intent === "collect_method_evidence" || intent === ""); }); } 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 = topicDomain(topic); const questionId = topicQuestionId(topic); 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 = { education: "education_style", relocation: "home_change", relationship: "relationship_style", career: "career_style", family: "family_event", finance: "finance_change", health_pressure: "health_pressure", } as const; const REVERSE_VERIFY_KIND = { education: "education_milestone", relocation: "home_change", relationship: "relationship_change", career: "career_change", family: "family_event", finance: "finance_change", health_pressure: "self_health_event", } as const; const REVERSE_VERIFY_VARGA = { education: "D5 / D24", relocation: "D4", relationship: "D9", career: "D10", family: "D12 / D7 / D3", finance: "D2 / D11", health_pressure: "D30", } as const; const PROBE_METHOD_ID = { education: "d5_education", relocation: "d4_home", relationship: "d9_relationship", career: "d10_career", family: "relatives", finance: "d2_finance", health_pressure: "d30_health", } as const; function contrastFollowupDomain( domain: string | null, ): keyof typeof REVERSE_VERIFY_THEME { if (domain && domain in REVERSE_VERIFY_THEME) { return domain as keyof typeof REVERSE_VERIFY_THEME; } return "career"; } function eventProbeFromContrast(probe: CandidateDiscriminatorProbe): DiscriminatingEventProbe | null { const domain = contrastFollowupDomain(probe.domain); if (!EVENT_PROBE_DOMAINS.includes(domain as EventProbeDomain)) return null; const completed = withCompletedContrastOptions(probe); if (!completed.ok) return null; const working = completed.probe; const choiceKind = working.choiceKind ?? "existence"; const styleOptions = (working.styleOptions ?? []).map((item) => ({ label: item.label, answer_class: item.answerClass, ...(item.sign ? { sign: item.sign } : {}), })); const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; return { year: working.year ?? 0, year_label: working.year ? `${working.year} 年前后` : "当前这几个候选", domain: domain as EventProbeDomain, event_family: followupEventFamily(domain, choiceKind), source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: working.question, role: "distinguish", phase: "candidate_discriminator", information_gain: working.informationGain, semantic_key: working.semanticKey, candidate_split_hash: working.candidateSplitHash, candidate_ids: candidateIds, expected_outcomes: working.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })), choice_kind: choiceKind, style_options: styleOptions, }; } function liveDistinguishProbe( focus: MethodFollowupFocus, eventProbes: readonly DiscriminatingEventProbe[] | undefined, contrastProbes: readonly CandidateDiscriminatorProbe[] | undefined, ): DiscriminatingEventProbe | null { const schemaKey = persistedFocusProbeKey(focus); const matchEvent = (probe: DiscriminatingEventProbe) => ( isValidDistinguishProbe({ ...probe, role: "distinguish" }) && (schemaKey ? probe.semantic_key === schemaKey : (!focus.targetDomain || probe.domain === focus.targetDomain)) ); const fromEvents = (eventProbes ?? []).find(matchEvent) ?? null; if (fromEvents) return fromEvents; const matchContrast = (probe: CandidateDiscriminatorProbe) => ( schemaKey ? probe.semanticKey === schemaKey : (!focus.targetDomain || probe.domain === focus.targetDomain) ); const fromContrast = (contrastProbes ?? []).find(matchContrast); if (!fromContrast) return null; const converted = eventProbeFromContrast(fromContrast); return converted && isValidDistinguishProbe(converted) ? converted : null; } const CONFLICT_PROBE_SOURCES = new Set([ "dasha_boundary", "dasha_activation", ]); function reverseVerifyProbeAsked( probe: DiscriminatingEventProbe, askedKeys: ReadonlySet, ): boolean { const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const split = probe.candidate_split_hash ?? ""; const exact = askedKeys.has(semantic) || (split !== "" && askedKeys.has(split)); if (isEventQualityProbe(probe)) return exact; return exact || askedKeys.has(`${probe.domain}.${probe.year}`) || existenceProbeAsked(askedKeys, probe.domain, probe.year); } export function remainingReverseVerifyProbes( probes: readonly DiscriminatingEventProbe[] | undefined, evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, askedKeys: ReadonlySet = new Set(), birthDate?: string | null, ): DiscriminatingEventProbe[] { const dasha: DiscriminatingEventProbe[] = []; const fallback: DiscriminatingEventProbe[] = []; for (const probe of probes ?? []) { const anchoredQuality = probe.source === "known_event_quality" && Boolean(probe.target_evidence_id) && probe.role === "distinguish"; if ((probe.source === "known_event_quality" && !anchoredQuality) || probe.role === "clarify" || probe.phase === "event_clarification") continue; if (probe.role === "collect" || probe.phase === "evidence_collection") continue; if (declined.has(probe.domain)) continue; if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; if (probeBelowAdultFloor(probe, birthDate)) continue; if (reverseVerifyProbeAsked(probe, askedKeys)) continue; if (CONFLICT_PROBE_SOURCES.has(probe.source)) { dasha.push(probe); } else { fallback.push(probe); } } return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY); } export function reverseVerifyChecksFromProbes( probes: readonly DiscriminatingEventProbe[], ): ReadonlyArray<{ kind: "reverse_verify"; domain: string; year_label: string }> { return probes.map((probe) => ({ kind: "reverse_verify" as const, domain: probe.domain, year_label: probe.year_label, })); } export function reverseVerifyRemainingForAdopt(input: { eventProbes?: readonly DiscriminatingEventProbe[]; evidence: readonly MethodFollowupEvidence[]; declinedTopics?: readonly Readonly>[]; askedProbeKeys?: readonly string[]; birthDate?: string | null; }): DiscriminatingEventProbe[] { return remainingReverseVerifyProbes( input.eventProbes, input.evidence, declinedDomains(input.declinedTopics ?? [], { includeSkipped: false }), new Set(input.askedProbeKeys ?? []), input.birthDate, ); } function datedCollectionProbe( probes: readonly DiscriminatingEventProbe[] | undefined, domain: string, ): DiscriminatingEventProbe | null { const rows = (probes ?? []).filter((item) => item.domain === domain && Number(item.year) > 0); return rows.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))[0] ?? null; } function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick< MethodFollowup, "probe_year" | "probe_month" | "semantic_key" > { if (!probe) return {}; return { probe_year: probe.year, ...(probe.month ? { probe_month: probe.month } : {}), semantic_key: probe.semantic_key, }; } function remainingConflictProbes( probes: readonly DiscriminatingEventProbe[] | undefined, evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, askedKeys: ReadonlySet = new Set(), birthDate?: string | null, ): DiscriminatingEventProbe[] { const rows: DiscriminatingEventProbe[] = []; for (const probe of probes ?? []) { if (!CONFLICT_PROBE_SOURCES.has(probe.source)) continue; if (probe.source === "known_event_quality" || probe.role === "clarify") continue; if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) continue; if (declined.has(probe.domain)) continue; if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; if (probeBelowAdultFloor(probe, birthDate)) continue; const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const split = probe.candidate_split_hash ?? ""; if ( askedKeys.has(semantic) || (split && askedKeys.has(split)) || sameYearProbeAsked(askedKeys, probe.domain, probe.year) ) continue; rows.push(probe); } return rows .sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0)) .slice(0, MAX_REVERSE_VERIFY); } type RankedDiscriminator = Readonly<{ kind: "event" | "contrast"; score: number; eventProbe?: DiscriminatingEventProbe; contrastProbe?: CandidateDiscriminatorProbe; styleOptions: ProbeStyleOption[]; }>; function droppedFromProbe( semanticKey: string, informationGain: number, reason: DroppedProbe["reason"], ): DroppedProbe { return { semantic_key: semanticKey, information_gain: informationGain, reason, }; } function activeSplitForRanking( outcomes: readonly Readonly<{ answer_class?: string; outcomeId?: string; supports?: readonly string[]; supportsCandidateIds?: readonly string[]; }>[], originalGain: number, topCandidateTimes: readonly string[], ): { dropped: DroppedProbe | null; informationGain: number } | null { if (topCandidateTimes.length < 2) return { dropped: null, informationGain: originalGain }; const split = informationGainAmongActive(outcomes, topCandidateTimes); if (split.splits) return { dropped: null, informationGain: split.informationGain }; return null; } function renderableEventProbe( probe: DiscriminatingEventProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], mentionedKeys: ReadonlySet = new Set(), ): { row: RankedDiscriminator | null; dropped: DroppedProbe | null } { const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe); const styleOptions = completeStyleOptions({ choiceKind: probe.choice_kind, styleOptions: probe.style_options, }); if (!styleOptions.ok) { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, styleOptions.reason) }; } if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "not_renderable") }; } const renderable = isRenderableProbe({ informationGain: probe.information_gain, candidateIds, expectedOutcomeCount: probe.expected_outcomes?.length, choiceKind: probe.choice_kind, styleOptions: styleOptions.options, year: probe.year, yearLabel: probe.year_label, }); if (!renderable.ok && renderable.reason !== "yearless_ungrounded_contrast") { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason) }; } const rankedGain = activeSplitForRanking( probe.expected_outcomes ?? [], probe.information_gain ?? 0, topCandidateTimes, ); if (!rankedGain) { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "no_split_among_active") }; } const yearAskedKeys = askedKeysForSameYearDedup(askedKeys, probe); if (sameYearProbeAsked(yearAskedKeys, probe.domain, probe.year)) { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "same_year_asked") }; } const layer = vargaLayerFromSemanticKey(key); const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)) || existenceProbeAsked(yearAskedKeys, probe.domain, probe.year) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { row: { kind: "event", eventProbe: probe, styleOptions: styleOptions.options, score: rankDiscriminatorScore({ informationGain: rankedGain.informationGain, asked, candidateIds, topCandidateTimes, }), }, dropped: renderable.ok ? null : droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason), }; } function renderableContrastProbe( probe: CandidateDiscriminatorProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], mentionedKeys: ReadonlySet = new Set(), ): { row: RankedDiscriminator | null; dropped: DroppedProbe | null } { const completed = withCompletedContrastOptions(probe); if (!completed.ok) { return { row: null, dropped: droppedFromProbe(probe.semanticKey, probe.informationGain, completed.reason), }; } const working = completed.probe; const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; const styleOptions = (working.styleOptions ?? []).map((item) => ({ label: item.label, answer_class: item.answerClass, ...(item.sign ? { sign: item.sign } : {}), })); const renderable = isRenderableProbe({ informationGain: working.informationGain, candidateIds, expectedOutcomeCount: working.expectedOutcomes.length, choiceKind: working.choiceKind, styleOptions, year: working.year, }); if (!renderable.ok && renderable.reason !== "yearless_ungrounded_contrast") { return { row: null, dropped: droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason), }; } const rankedGain = activeSplitForRanking( working.expectedOutcomes, working.informationGain, topCandidateTimes, ); if (!rankedGain) { return { row: null, dropped: droppedFromProbe(working.semanticKey, working.informationGain, "no_split_among_active"), }; } const yearAskedKeys = askedKeysForSameYearDedup(askedKeys, working); if (sameYearProbeAsked(yearAskedKeys, working.domain, working.year)) { return { row: null, dropped: droppedFromProbe(working.semanticKey, working.informationGain, "same_year_asked"), }; } const layer = vargaLayerFromSemanticKey(working.semanticKey); const asked = askedKeys.has(working.semanticKey) || askedKeys.has(working.candidateSplitHash) || askedKeys.has(working.probeId) || existenceProbeAsked(yearAskedKeys, working.domain ?? "", working.year ?? 0) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { row: { kind: "contrast", contrastProbe: working, styleOptions, score: rankDiscriminatorScore({ informationGain: rankedGain.informationGain, asked, candidateIds, topCandidateTimes, }), }, dropped: renderable.ok ? null : droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason), }; } const EXISTENCE_EVENT_FAMILY: Record = { education: "升学、转学或换学习环境", relocation: "搬家或长期住到外地", relationship: "开始一段认真关系、分手或结婚", career: "入职、换工作或职责加重", family: "家人结婚、添丁或住院", finance: "收入明显变化、大笔支出或欠债", health_pressure: "生病、受伤或压力特别大", }; const QUALITY_EVENT_FAMILY: Record = { education: "学业或考试发挥失常、压力特别大", relocation: "搬家或住处特别折腾", relationship: "感情里压力特别大或相处明显变难", career: "职责加重或工作压力特别大", family: "家人结婚、添丁、住院或家里特别操心", finance: "收入或财务压力特别大", health_pressure: "生病、受伤或压力特别大", }; function followupEventFamily(domain: string, kind: string): string { if (kind === "event_quality") { return QUALITY_EVENT_FAMILY[domain] ?? "这件事比平时更难、压力特别大"; } if (kind === "varga_style") { return domain === "relationship" ? "相处方式更接近其中一种" : "做事风格更接近其中一种"; } return EXISTENCE_EVENT_FAMILY[domain] ?? "这段经历是否发生过"; } function followupOwnedProbe( item: Omit, ): DiscriminatingEventProbe | null { if (!item.style_options?.length) return null; if (!item.domain || !EVENT_PROBE_DOMAINS.includes(item.domain as EventProbeDomain)) return null; const kind = item.choice_kind ?? "existence"; if (kind === "block_choice" || kind === "widen_window") return null; const styleOptions: EventProbeStyleOption[] = []; for (const row of item.style_options) { const answer = row.answer_class; if (answer !== "yes" && answer !== "weak_yes" && answer !== "no" && answer !== "unsure") return null; styleOptions.push({ label: row.label, answer_class: answer, ...(row.sign ? { sign: row.sign } : {}), }); } if (styleOptions.length !== 4) return null; return { year: item.probe_year ?? 0, year_label: item.year_label ?? (item.probe_year && item.probe_month ? `${item.probe_year} 年 ${item.probe_month} 月前后` : item.probe_year ? `${item.probe_year} 年前后` : "当前这几个候选"), ...(item.probe_month ? { month: item.probe_month } : {}), domain: item.domain as EventProbeDomain, event_family: followupEventFamily(item.domain, kind), source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: item.user_prompt_hint, role: "distinguish", phase: "candidate_discriminator", information_gain: item.information_gain, semantic_key: item.semantic_key, candidate_split_hash: item.candidate_split_hash, candidate_ids: item.candidate_ids, expected_outcomes: item.expected_outcomes, choice_kind: kind, style_options: styleOptions, }; } function persistedFocusProbeKey(focus: MethodFollowupFocus | null | undefined): string { const key = focus?.expectedAnswerSchema?.semantic_key; return typeof key === "string" && key.trim() ? key.trim() : ""; } function rankedDiscriminatorKey(row: RankedDiscriminator | null): string { if (!row) return ""; return row.eventProbe?.semantic_key ?? row.contrastProbe?.semanticKey ?? ""; } function discriminatorChoiceKind(row: RankedDiscriminator): string { return row.eventProbe?.choice_kind ?? row.contrastProbe?.choiceKind ?? "existence"; } function discriminatorLocksScoringPeriod(row: RankedDiscriminator): boolean { if (discriminatorChoiceKind(row) === "varga_style") return false; const year = row.eventProbe?.year ?? row.contrastProbe?.year ?? 0; return year > 0; } function isYearlessPersonalityRow(row: RankedDiscriminator): boolean { return discriminatorChoiceKind(row) === "varga_style"; } /** Dated reverse-inference only. D9/D10 / nakshatra never count (BUG-651). */ function followupLocksDatedPeriod(followup: MethodFollowup | null | undefined): boolean { if (!followup) return false; if (followup.choice_kind === "varga_style") return false; if (followup.source === "nakshatra_boundary") return false; if ((followup.probe_year ?? 0) > 0) return true; const period = followup.choice_frame?.period ?? ""; return /(?:19|20)\d{2}/.test(period); } export function yearlessPersonalityCanAsk(input: { datedCount: number; candidatesSeparated: boolean; topCandidateTimes?: readonly string[]; }): boolean { if (input.datedCount > 0 || input.candidatesSeparated) return false; const times = input.topCandidateTimes; if (times !== undefined && times.length < 2) return false; return true; } function emptyRankedCatalog(): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[]; } { return { locked: [], personality: [], yearless: [], dropped: [] }; } function rankRenderableDiscriminators(input: { eventProbes: readonly DiscriminatingEventProbe[]; contrastProbes: readonly CandidateDiscriminatorProbe[]; askedKeys: ReadonlySet; mentionedKeys?: ReadonlySet; topCandidateTimes?: readonly string[]; providedDomains?: readonly string[]; evidence?: readonly MethodFollowupEvidence[]; birthDate?: string | null; }): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } { const top = input.topCandidateTimes ?? []; const provided = new Set(input.providedDomains ?? []); const mentioned = input.mentionedKeys ?? new Set(); const rows: RankedDiscriminator[] = []; const dropped: DroppedProbe[] = []; const seen = new Set(); const push = (result: { row: RankedDiscriminator | null; dropped: DroppedProbe | null }) => { if (result.dropped) dropped.push(result.dropped); const row = result.row; if (!row) return; const key = row.eventProbe?.semantic_key ?? row.contrastProbe?.semanticKey ?? ""; if (!key || seen.has(key)) return; seen.add(key); rows.push(row); }; for (const probe of input.eventProbes) { push(renderableEventProbe(probe, input.askedKeys, top, mentioned)); } for (const probe of input.contrastProbes) { if ( probe.domain && probeBelowAdultFloor({ domain: probe.domain, year: probe.year ?? 0 }, input.birthDate) ) { continue; } if (probe.choiceKind === "varga_style") { const domain = contrastFollowupDomain(probe.domain); const anchor = datedLedgerAnchor(input.evidence, domain); if (!anchor) { dropped.push(droppedFromProbe(probe.semanticKey, probe.informationGain, "unanchored_varga_style")); continue; } } if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) { const year = probe.year ?? 0; if (year <= 0) continue; if (input.evidence && probeYearAlreadyCovered(input.evidence, probe.domain, year)) continue; } const rendered = renderableContrastProbe(probe, input.askedKeys, top, mentioned); if (rendered.row && probe.choiceKind === "varga_style") { rendered.row = { ...rendered.row, score: rendered.row.score * 0.8 }; } push(rendered); } const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0)); return { locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)), personality: sorted.filter((row) => isYearlessPersonalityRow(row)), yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)), dropped, }; } function coverage( methodId: MethodFollowupId, status: MethodCoverageStatus, ): MethodCoverage { return { method_id: methodId, status }; } function collectHint( why: string, varga: string, extra = "", evidence: readonly MethodFollowupEvidence[] = [], ): string { return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}用 rectification-set-focus 的 spokenPrompt 用自然语言写出采集题。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。正文不提问,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim(); } function agentHint( why: string, varga: string, extra = "", evidence: readonly MethodFollowupEvidence[] = [], ): string { return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。正文不要提问、不要复述选项。`.replace(/\s+/g, " ").trim(); } export function shouldAttachChoiceFrame( item: Pick, evidence: readonly MethodFollowupEvidence[] = [], ): boolean { if (item.intent === "out_of_sample_check" || item.source === "oos_blind") { return (item.probe_year ?? 0) > 0; } if (item.intent === "reverse_verify" || item.source === "reverse_verify") return true; if (item.intent === "clarify_event") return true; if (item.source === "event_probe") return true; if (item.ask_theme === "nakshatra_trait" || item.source === "nakshatra_boundary") return true; if (!evidence.some(isConfirmedDated)) return false; if (item.intent === "distinguish_candidates") return true; if (item.source === "varga_observation" || item.source === "precision_stage") return true; return false; } export function spokenFollowupForUser( followup: MethodFollowup | null, evidence: readonly MethodFollowupEvidence[] = [], ): string | null { if (!followup) return null; if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null; if (followup.date_reliability_evidence_id) return DATE_RELIABILITY_PROMPT; if (followup.intent !== "collect_method_evidence") return null; if (followup.spoken_prompt?.trim()) return followup.spoken_prompt.trim(); const domain = followup.domain ?? ""; const openingOther = followup.source === "method_coverage" && followup.method_id === "dasha_events" && domain === "other" && followup.collect_retry !== true && !evidence.some(isConfirmedDated); if (domain === "other") { return openingOther ? GENERIC_COLLECT_QUESTION : null; } const mapped = followup.collect_retry === true ? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain]) : collectQuestionForDomain(domain); if (!mapped) return null; const dated = (DATED_COLLECT_ORDER as readonly string[]).includes(domain); if (followup.invite_more_once && dated && followup.collect_retry !== true) { return withFirstDatedCollectInvite(mapped); } return mapped; } export const DATED_COLLECT_ORDER = [ "family", "education", "finance", "relocation", "health_pressure", "career", "relationship", ] as const; function datedCollectDomainBlocked( domain: (typeof DATED_COLLECT_ORDER)[number], evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, answeredYes: ReadonlySet = new Set(), ): boolean { if (domain === "health_pressure") { return declinedHealth(declined) || hasConfirmedHealth(evidence) || answeredYes.has("health_pressure") || answeredYes.has("health"); } return declined.has(domain) || hasConfirmedDomain(evidence, domain) || answeredYes.has(domain); } export function datedCollectAlreadyAsked( declined: ReadonlySet, topics: readonly Readonly>[] = [], ): boolean { if (DATED_COLLECT_ORDER.some((domain) => declined.has(domain))) return true; return DATED_COLLECT_ORDER.some((domain) => domainCollectFocusAsked(topics, domain)); } export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup { if (item.kind === "invite") { return { method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: "other", kind_hint: "invite_more", user_prompt_hint: item.prompt, must_not_label: false, choice_frame: null, source: "method_coverage", spoken_prompt: item.prompt, collection_key: item.key, }; } const domain = item.domain; const methodId = domain in PROBE_METHOD_ID ? PROBE_METHOD_ID[domain as keyof typeof PROBE_METHOD_ID] : "dasha_events"; const theme = domain in REVERSE_VERIFY_THEME ? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME] : "dated_event"; const kindHint = item.kind === "anchor" && item.targetKind && item.year != null ? `anchor:${item.targetKind}:${item.year}` : item.kind === "generic" ? `generic:${domain}` : domain in REVERSE_VERIFY_KIND ? REVERSE_VERIFY_KIND[domain as keyof typeof REVERSE_VERIFY_KIND] : null; return { method_id: methodId, intent: "collect_method_evidence", ask_theme: theme, domain, kind_hint: kindHint, user_prompt_hint: item.prompt, must_not_label: false, choice_frame: null, source: "method_coverage", spoken_prompt: item.prompt, collection_key: item.key, ...(item.year ? { probe_year: item.year, year_label: `${item.year} 年` } : {}), }; } export function nextCollectionFollowup( evidence: readonly MethodFollowupEvidence[], declinedTopics: readonly Readonly>[] = [], ): MethodFollowup | null { const top = collectionQuestionPool(evidence, declinedTopics)[0]; return top ? followupFromPoolItem(top) : null; } export function nextDatedCollectFollowup( evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, answeredYes: ReadonlySet = new Set(), options?: { askedDatedCollect?: boolean; collectionProbes?: readonly DiscriminatingEventProbe[]; declinedTopics?: readonly Readonly>[]; }, ): MethodFollowup | null { const topics = [ ...(options?.declinedTopics ?? []), ...[...declined].map((domain) => ({ target_domain: domain, status: "declined" })), ...[...answeredYes].map((domain) => ({ target_domain: domain, status: "resolved" })), ]; return nextCollectionFollowup(evidence, topics); } const REMAINING_EVIDENCE_COLLECT_DOMAINS = new Set([ ...DATED_COLLECT_ORDER, "occupation", ]); /** Spoken collect that can still add distinguishing evidence. Includes the S1 pool. */ export function isRemainingEvidenceCollect( followup: { intent: string; domain: string | null; date_reliability_evidence_id?: string | null; collection_key?: string; kind_hint?: string | null; } | null | undefined, ): boolean { if (!followup || followup.intent !== "collect_method_evidence") return false; if (followup.date_reliability_evidence_id) return false; const key = followup.collection_key ?? ""; if ( key.startsWith("collect:invite:") || key.startsWith("collect:anchor:") || key.startsWith("collect:generic:") ) return true; const hint = followup.kind_hint ?? ""; if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:")) return true; return typeof followup.domain === "string" && REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain); } /** Remaining spoken collect that can still add a dated event in an uncovered domain. */ export function remainingEvidenceCollectStillOpen( followup: Pick | null | undefined, evidence: readonly MethodFollowupEvidence[] = [], declinedTopics: readonly Readonly>[] = [], ): boolean { if (!isRemainingEvidenceCollect(followup) || !followup?.domain) return false; const declined = declinedDomains(declinedTopics); if (followup.domain === "occupation") { return !hasConfirmedDomain(evidence, "occupation") && !declined.has("occupation"); } if ((DATED_COLLECT_ORDER as readonly string[]).includes(followup.domain)) { return !datedCollectDomainBlocked( followup.domain as (typeof DATED_COLLECT_ORDER)[number], evidence, declined, ); } return true; } function holdoutOccupiedDomains(evidence: readonly MethodFollowupEvidence[]): Set { const occupied = new Set( evidence .filter((item) => isConfirmedDated(item) && evidenceYear(item) != null) .map((item) => item.domain), ); if (hasConfirmedHealth(evidence) || occupied.has("health") || occupied.has("health_pressure")) { occupied.add("health"); occupied.add("health_pressure"); } return occupied; } export function exhaustionSpokenCollectFollowup(input: { evidence: readonly MethodFollowupEvidence[]; declinedTopics?: readonly Readonly>[]; answeredProbes?: readonly AnsweredProbeCoverageRow[]; eventProbes?: readonly DiscriminatingEventProbe[]; }): MethodFollowup | null { if (trainingScoreableGate(input.evidence).open) return null; return nextCollectionFollowup(input.evidence, input.declinedTopics ?? []); } export const OPENING_COLLECT_DOMAIN = "other"; export function collectQuestionDomain(domain: string | null | undefined): string { if (domain && domain !== "unknown" && domain !== "active_focus") return domain; return OPENING_COLLECT_DOMAIN; } export function parseCollectFocusQuestionId(questionId: string | null | undefined): { domain: string; } | null { if (!questionId) return null; const match = /^collect:([^:]+):/.exec(questionId.trim()); const domain = match?.[1]?.trim() ?? ""; if (!domain) return null; return { domain }; } export function ledgerHasConfirmedDatedEvent( evidence: readonly Pick[], ): boolean { return evidence.some(isConfirmedDated); } export function parsePersistedFollowupQuestionId(questionId: string | null | undefined): { method_id: string; ask_theme: string; scoring: boolean; } | null { if (!questionId) return null; const match = /^(.*):([^:]+):(score|holdout)$/.exec(questionId.trim()); if (!match) return null; const method_id = match[1]?.trim() ?? ""; const ask_theme = match[2]?.trim() ?? ""; if (!method_id || !ask_theme) return null; return { method_id, ask_theme, scoring: match[3] === "score" }; } function parsedIdScoring(questionId: string | null | undefined): boolean { return parsePersistedFollowupQuestionId(questionId)?.scoring !== false; } export function spokenCollectFallbackFollowup(followup: MethodFollowup): MethodFollowup { return { method_id: followup.method_id, intent: "collect_method_evidence", ask_theme: followup.ask_theme, domain: collectQuestionDomain(followup.domain), kind_hint: followup.kind_hint, user_prompt_hint: followup.user_prompt_hint, must_not_label: false, choice_frame: null, source: "method_coverage", ...(followup.spoken_prompt ? { spoken_prompt: followup.spoken_prompt } : {}), ...(followup.collection_key ? { collection_key: followup.collection_key } : {}), }; } export type NextUserActionId = | "adopt_representative" | "score_now" | "record_stated_events" | "ask_method_followup" | "ask_candidate_discriminator" | "ask_holdout_validation" | "ask_block_choice" | "ask_window_widen" | "offer_provisional_range" | "explain_current_window" | "verify_adopted_time" | "start_consultation"; export type NextUserAction = Readonly<{ id: NextUserActionId; user_meaning: string; on_user_stop: { id: Exclude; user_meaning: string; }; }>; function action( id: NextUserAction["on_user_stop"]["id"], user_meaning: string, ): NextUserAction["on_user_stop"] { return { id, user_meaning }; } export function isOfferBlockingFollowup( followup: MethodFollowup | null, methods?: readonly MethodCoverage[], options?: { separated?: boolean }, ): boolean { if (methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered")) { return true; } if (!followup) return false; const separated = options?.separated === true; if (followup.source === "event_probe") { if (separated) return (followup.information_gain ?? 0) >= 0.08; return true; } if ( followup.source === "varga_observation" || followup.source === "precision_stage" || followup.intent === "distinguish_candidates" ) { return !separated; } if (followup.source !== "method_coverage") return false; return BLOCKING_COVERAGE_IDS.has(followup.method_id as MethodFollowupId); } function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null { if (!followup) return null; if (followup.choice_kind === "block_choice" || followup.choice_kind === "widen_window") return null; if (followup.intent === "clarify_event") return null; if (followup.choice_kind === "event_quality" && followup.intent !== "distinguish_candidates") return null; const realProbe = followup.source === "event_probe" || followup.source === "reverse_verify" || (followup.source === "active_focus" && followup.intent === "distinguish_candidates"); if (!realProbe) return null; const outcomes = (followup.expected_outcomes ?? []).filter((row) => row.supports.length + row.conflicts.length > 0); const candidateIds = followup.candidate_ids ?? candidateIdsFromProbe({ role: "distinguish", expected_outcomes: followup.expected_outcomes, candidate_ids: followup.candidate_ids, }); if ((followup.information_gain ?? 0) <= 0 || outcomes.length < 2 || candidateIds.length < 2) { return null; } const split = followup.candidate_split_hash ?? followup.semantic_key ?? followup.method_id; return { probeId: split, candidateSetVersion: split, question: followup.user_prompt_hint, expectedOutcomes: outcomes.map((row) => ({ outcomeId: row.answer_class, supportsCandidateIds: row.supports, conflictsCandidateIds: row.conflicts, })), candidateSplitHash: split, informationGain: followup.information_gain ?? 0, sourceFeatures: [{ technique: followup.source, calculationResultId: null }], domain: followup.domain, year: followup.probe_year ?? null, semanticKey: followup.semantic_key ?? followup.method_id, choiceKind: followup.choice_kind, styleOptions: followup.style_options?.map((item) => ({ label: item.label, answerClass: item.answer_class as "yes" | "weak_yes" | "no" | "unsure", ...(item.sign ? { sign: item.sign } : {}), })), }; } export function decideConversationalSession(input: { selectionAllowed: boolean; proposeAllowed: boolean; confirmationAllowed: boolean; nextFollowup: MethodFollowup | null; methods?: readonly MethodCoverage[]; userStopped?: boolean; candidateScores?: readonly Readonly<{ time: string; score: number }>[]; trainingGateOpen?: boolean; evidence?: readonly MethodFollowupEvidence[]; discriminatorProbe?: CandidateDiscriminatorProbe | null; holdoutValidation?: HoldoutValidationStatus; snapshotCurrent?: boolean; accepted?: boolean; inferenceRounds?: number; effectiveAnswerCount?: number; plateauRounds?: number; }): ReturnType { const coverageOpen = Boolean( input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"), ); const trainingOpen = input.trainingGateOpen ?? (input.evidence ? trainingScoreableGate(input.evidence).open : true); return decideRectification({ methodCoverageAll: !coverageOpen, trainingGateOpen: trainingOpen, confirmationAllowed: input.confirmationAllowed, userStopped: input.userStopped, snapshotCurrent: input.snapshotCurrent, candidateScores: input.candidateScores ?? [], discriminatorProbe: input.discriminatorProbe !== undefined ? input.discriminatorProbe : discriminatorFromFollowup(input.nextFollowup), holdoutValidation: input.holdoutValidation, accepted: input.accepted, // This helper is not used by the production V9 delivery path; keep its // delivery ceiling explicit without coupling it to question selection. engineCeiling: { acceptanceAllowed: input.selectionAllowed === true, selectionAllowed: input.selectionAllowed === true, proposeAllowed: input.proposeAllowed === true, confirmationAllowed: input.confirmationAllowed === true, }, datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods, input.evidence) : undefined, inferenceRounds: input.inferenceRounds, effectiveAnswerCount: input.effectiveAnswerCount, plateauRounds: input.plateauRounds, }); } export function conversationalSessionOutcome(input: Parameters[0]): SessionOutcomeKind { return decideConversationalSession(input).sessionOutcome; } export function buildNextUserAction(input: { scorableCount: number; evidenceCount: number; hasLatestResult: boolean; selectionAllowed: boolean; sessionOutcome: SessionOutcomeKind; nextFollowup: MethodFollowup | null; workingTime: string | null; accepted?: boolean; }): NextUserAction { const working = input.workingTime ? `当前排盘时间是 ${input.workingTime}` : "当前还没有可采用的校正时间"; if (input.accepted) { const consult = action( "start_consultation", "前事核对结束。请用户用当前采用时间看盘;对不上可改选其他候选。不要声称已确认唯一分钟。", ); if (input.nextFollowup) { return { id: "verify_adopted_time", user_meaning: input.nextFollowup.user_prompt_hint, on_user_stop: consult, }; } return { id: consult.id, user_meaning: consult.user_meaning, on_user_stop: consult }; } const adopt = action( "adopt_representative", "已有可采用的代表性候选时间。请用户从下方时间卡片选择;采用后再用该时间看盘。不得把代表性候选说成已确认的唯一出生分钟。", ); const provisional = action( "offer_provisional_range", "当前几个候选基本并列。说明这是可信区间,代表分钟只是计算用的代表点,不要称某分钟为当前推荐;不要继续假装已经收敛。", ); const completedRange = action( "offer_provisional_range", `本次区间交付已完成。${RECTIFICATION_TERMINATION_COPY}`, ); if (input.sessionOutcome === "awaiting_confirmation") { return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt }; } if (input.sessionOutcome === "adopt_representative") { return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt }; } if (input.sessionOutcome === "provisional_range") { return { id: provisional.id, user_meaning: provisional.user_meaning, on_user_stop: provisional }; } if (input.sessionOutcome === "completed_with_range" || input.sessionOutcome === "provisional_range_user_stopped") { return { id: completedRange.id, user_meaning: completedRange.user_meaning, on_user_stop: completedRange, }; } if (input.sessionOutcome === "validated_range" || input.sessionOutcome === "exact_minute_confirmed") { return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt }; } if (input.sessionOutcome === "validate_holdout" && input.nextFollowup) { return { id: "ask_holdout_validation", user_meaning: input.nextFollowup.user_prompt_hint, on_user_stop: input.selectionAllowed ? provisional : action( "explain_current_window", `${working}。独立核对还没做完。用户说没有更多时,说明这是并列区间,不要采用一张赢家卡。`, ), }; } if (input.sessionOutcome === "discriminate_candidates" && input.nextFollowup) { return { id: "ask_candidate_discriminator", user_meaning: input.nextFollowup.user_prompt_hint, on_user_stop: input.selectionAllowed ? provisional : action( "explain_current_window", `${working}。候选还没拉开。用户说没有更多时,给出并列可信区间,不要宣布某分钟胜出。`, ), }; } if (input.sessionOutcome === "compare_blocks" && input.nextFollowup) { return { id: "ask_block_choice", user_meaning: input.nextFollowup.user_prompt_hint, on_user_stop: action( "record_stated_events", "说不好就再收一件带年份的经历。不要给出分钟或采用卡。", ), }; } if (input.sessionOutcome === "widen_window" && input.nextFollowup) { return { id: "ask_window_widen", user_meaning: input.nextFollowup.user_prompt_hint, on_user_stop: action( "explain_current_window", "不放宽就按现在的范围继续。不要采用时间卡。", ), }; } const scoreNow = action( "score_now", "已有可评分事件但还没有候选结果。本轮必须比较候选,不要只口头确认事件。", ); if (input.scorableCount > 0 && !input.hasLatestResult) { return { id: scoreNow.id, user_meaning: scoreNow.user_meaning, on_user_stop: scoreNow }; } const record = action( "record_stated_events", "账本里还没有带日期事件。若用户已经说过带日期的经历,本轮必须用 batch 写入后再比较;不要只在口头上复述。", ); if (input.evidenceCount === 0 && input.scorableCount === 0) { return { id: record.id, user_meaning: record.user_meaning, on_user_stop: record }; } const explain = action( "explain_current_window", `${working}。现有事件还不够给出可采用的代表性时间。用户说没有更多时,说明缺什么、可以以后再补,或先用当前填报时间去咨询看盘;不要只说会话会保留。`, ); if (input.nextFollowup) { return { id: "ask_method_followup", user_meaning: input.nextFollowup.user_prompt_hint, on_user_stop: input.hasLatestResult ? provisional : explain, }; } return { id: explain.id, user_meaning: explain.user_meaning, on_user_stop: explain }; } function holdoutAskFields( prompt: OosBlindPrompt | null | undefined, reserved: Readonly<{ domain: string; year: number | null }> | null, ): Omit | null { const domain = prompt?.domain || reserved?.domain || ""; if (!domain || !USER_COLLECT_QUESTION[domain]) return null; return { method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain, kind_hint: null, user_prompt_hint: USER_COLLECT_QUESTION[domain], source: "method_coverage", }; } function declinedForHoldout(declined: ReadonlySet): Set { const next = new Set(declined); if (declinedHealth(declined)) { next.add("health"); next.add("health_pressure"); } return next; } export function holdoutFollowupFor( input: { evidence: readonly MethodFollowupEvidence[]; oosBlindPrompts?: readonly OosBlindPrompt[]; holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[]; }, declined: ReadonlySet, ): Omit | null { if (!meetsAcceptanceEventQuality(input.evidence)) return null; const occupied = holdoutOccupiedDomains(input.evidence); const blocked = declinedForHoldout(declined); const prompt = (input.oosBlindPrompts ?? []).find((item) => ( Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain) )) ?? null; const reserved = (input.holdoutEvents ?? []).find((item) => ( item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain) )) ?? null; return holdoutAskFields(prompt, reserved); } function widenWindowFollowup(input: { reportedTime: string; currentWindow: { start_time: string; end_time: string }; }): MethodFollowup | null { const frame = buildWidenWindowFrame(input); const windows = widenWindowsForChoice(input); if (!frame || !windows) return null; return { method_id: "dasha_events", intent: WIDEN_WINDOW_INTENT, ask_theme: "widen_window", domain: null, kind_hint: null, user_prompt_hint: frame.prompt, must_not_label: false, choice_frame: frame, source: "window_widen", choice_kind: WIDEN_WINDOW_KIND, semantic_key: "widen_window.search_range", widen_windows: windows, }; } function dateReliabilityFollowup( evidence: readonly MethodFollowupEvidence[], currentTurnId?: string | null, ): MethodFollowup | null { const pending = pendingDateReliabilityEvidence(evidence, currentTurnId); if (!pending) return null; const row = evidence.find((item) => item.id === pending.id); return { method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: row?.domain ?? "other", kind_hint: row?.eventKind ?? null, user_prompt_hint: DATE_RELIABILITY_PROMPT, must_not_label: false, choice_frame: null, source: "method_coverage", semantic_key: `date_reliability.${pending.id}`, date_reliability_evidence_id: pending.id, }; } export function planWithDateReliability( plan: MethodFollowupPlan, evidence: readonly MethodFollowupEvidence[], currentTurnId?: string | null, ): MethodFollowupPlan { if (plan.session_outcome !== "collect_evidence") return plan; const reliability = dateReliabilityFollowup(evidence, currentTurnId); if (!reliability) return plan; return { ...plan, next_followup: reliability }; } function blockChoiceFollowup(payload: BlockScanPayload | null | undefined): MethodFollowup | null { if (!payload) return null; const frame = buildBlockChoiceFrame(payload); const periods = blockPeriodsForChoice(payload); if (!frame || !periods) return null; return { method_id: "dasha_events", intent: BLOCK_CHOICE_INTENT, ask_theme: "birth_block", domain: null, kind_hint: null, user_prompt_hint: frame.prompt, must_not_label: false, choice_frame: frame, source: "block_scan", choice_kind: BLOCK_CHOICE_KIND, semantic_key: "block_scan.choose_birth_block", block_periods: periods, }; } export function buildMethodFollowupPlan(input: { evidence: readonly MethodFollowupEvidence[]; activeFocus?: MethodFollowupFocus | null; declinedTopics?: readonly Readonly>[]; observations?: readonly InternalVargaObservation[]; sessionOutcome?: SessionOutcomeKind; precisionStage?: PrecisionStageId | null; nakshatraProbe?: ConflictProbe | null; oosBlindPrompts?: readonly OosBlindPrompt[]; eventProbes?: readonly DiscriminatingEventProbe[]; eventClarificationProbes?: readonly DiscriminatingEventProbe[]; evidenceCollectionProbes?: readonly DiscriminatingEventProbe[]; askedProbeKeys?: readonly string[]; answeredProbes?: readonly AnsweredProbeCoverageRow[]; closedCollectFocuses?: readonly Readonly>[]; birthDate?: string | null; accepted?: boolean; candidatesSeparated?: boolean; contrastPacket?: CandidateContrastPacket | null; topCandidateTimes?: readonly string[]; holdoutValidation?: HoldoutValidationStatus; holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[]; candidateRanges?: readonly ProbeExplainCandidate[]; caseStage?: RectificationCaseStage; blockScan?: BlockScanPayload | null; reportedTime?: string | null; candidateRange?: { start_time: string; end_time: string } | null; }): MethodFollowupPlan { const makeFollowup = ( item: Omit, scoring = true, forceChoice?: boolean, ): MethodFollowup => { const base = { ...item, must_not_label: false as const }; const attach = forceChoice ?? shouldAttachChoiceFrame(base, input.evidence); const keyed = Boolean(base.semantic_key) && [ ...(input.eventProbes ?? []), ...(input.eventClarificationProbes ?? []), ...(input.evidenceCollectionProbes ?? []), ].some((probe) => probe.semantic_key === base.semantic_key); const ownedProbe = keyed ? null : followupOwnedProbe(base); const askedRows = [ ...(input.closedCollectFocuses ?? []), ...(input.declinedTopics ?? []), ]; const collectRetry = base.intent === "collect_method_evidence" && typeof base.domain === "string" && domainCollectFocusAsked(askedRows, base.domain); const inviteMoreOnce = base.intent === "collect_method_evidence" && typeof base.domain === "string" && (DATED_COLLECT_ORDER as readonly string[]).includes(base.domain) && !collectRetry && !datedCollectAlreadyAsked(declinedDomains(input.declinedTopics ?? []), askedRows); return { ...base, ...(collectRetry ? { collect_retry: true } : {}), ...(inviteMoreOnce ? { invite_more_once: true } : { invite_more_once: false }), choice_frame: attach ? buildChoiceFrame(base, { observations: input.observations, evidence: input.evidence, probes: [ ...(ownedProbe ? [ownedProbe] : []), ...(input.eventProbes ?? []), ...(input.eventClarificationProbes ?? []), ...(input.evidenceCollectionProbes ?? []), ], birthDate: input.birthDate, scoring, candidates: input.candidateRanges, }) : null, }; }; const collect = (why: string, varga: string, extra = "") => collectHint(why, varga, extra, input.evidence); const ask = (why: string, varga: string, extra = "") => agentHint(why, varga, extra, input.evidence); const declined = declinedDomains(input.declinedTopics ?? []); const reverseVerifyDeclined = declinedDomains(input.declinedTopics ?? [], { includeSkipped: false }); const answeredYes = domainsAnsweredYes(input.answeredProbes, [ ...(input.eventProbes ?? []), ...(input.eventClarificationProbes ?? []), ...(input.evidenceCollectionProbes ?? []), ]); const dashaCovered = input.evidence.some(isConfirmedDated); const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship") || answeredYes.has("relationship"); const careerCovered = hasConfirmedDomain(input.evidence, "career") || answeredYes.has("career"); const familyCovered = hasConfirmedDomain(input.evidence, "family") || answeredYes.has("family"); const financeCovered = hasConfirmedDomain(input.evidence, "finance"); const healthCovered = hasConfirmedHealth(input.evidence); const occupationCovered = hasConfirmedDomain(input.evidence, "occupation") || input.evidence.some(isOccupationNote) || declined.has("occupation") || (careerCovered && occupationCollectFocusClosed([ ...(input.declinedTopics ?? []), ...(input.closedCollectFocuses ?? []), ])); const horaryGiven = hasConfirmedDomain(input.evidence, "horary"); const horaryStatus: MethodCoverageStatus = horaryGiven ? "covered" : declined.has("horary") ? "skipped_by_policy" : "uncovered"; const methods: MethodCoverage[] = [ coverage("dasha_events", dashaCovered ? "covered" : "uncovered"), coverage("d9_relationship", relationshipCovered || declined.has("relationship") ? "covered" : "uncovered"), coverage("d10_career", careerCovered || declined.has("career") ? "covered" : "uncovered"), coverage("relatives", familyCovered || declined.has("family") ? "covered" : "uncovered"), coverage("appearance", "skipped_by_policy"), coverage("marks", "skipped_by_policy"), coverage("occupation", occupationCovered ? "covered" : "uncovered"), coverage("horary", horaryStatus), ]; const sessionOutcome = input.sessionOutcome; const candidatesSeparated = input.candidatesSeparated === true; const contrastProbes = candidatesSeparated ? [] : [...(input.contrastPacket?.probes ?? [])]; // Legacy known-event quality cards were never backed by an inference probe. // Ignore them so existing cases resume evidence collection instead of exposing a stale card. const focus = input.activeFocus?.intent === "clarify_event" ? null : input.activeFocus ?? null; const keepAcceptedFocus = Boolean( focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"), ); const coverageComplete = blockingMethodsCovered(methods); const mentionedKeys = new Set(mentionedVargaKeysFromLedgerEvidence(input.evidence)); const askedKeys = new Set([ ...(input.askedProbeKeys ?? []), ]); const rankedCatalog = input.caseStage === "block_scan" ? emptyRankedCatalog() : dashaCovered && meetsAcceptanceEventQuality(input.evidence) ? rankRenderableDiscriminators({ eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys, input.birthDate), contrastProbes, askedKeys, mentionedKeys, topCandidateTimes: input.topCandidateTimes, providedDomains: datedDomainsFromEvidence(input.evidence), evidence: input.evidence, birthDate: input.birthDate, }) : emptyRankedCatalog(); const rankedDiscriminators = rankedCatalog.locked; const personalityDiscriminators = rankedCatalog.personality; const yearlessDiscriminators = rankedCatalog.yearless; const bestDiscriminator = rankedDiscriminators[0] ?? null; const catalogWinnerKey = rankedDiscriminatorKey(bestDiscriminator); const staleCollectFocus = Boolean( focus && focus.intent === "collect_method_evidence" && ( (focus.targetDomain === "occupation" && occupationCovered) || (focus.targetDomain === "relationship" && (relationshipCovered || declined.has("relationship"))) || (focus.targetDomain === "career" && (careerCovered || declined.has("career"))) || (focus.targetDomain === "family" && (familyCovered || declined.has("family"))) || (focus.targetDomain === "horary" && horaryStatus !== "uncovered") || (input.caseStage === "block_scan" && sessionOutcome === "compare_blocks") || Boolean(focus.questionId?.startsWith("collect:other:") && dashaCovered) ), ); const staleDiscriminatorFocus = Boolean( focus && ( (focus.intent === "distinguish_candidates" && catalogWinnerKey && persistedFocusProbeKey(focus) && persistedFocusProbeKey(focus) !== catalogWinnerKey) || (focus.intent === BLOCK_CHOICE_INTENT && sessionOutcome !== "compare_blocks") ) ); if ( focus && !staleCollectFocus && !staleDiscriminatorFocus && (sessionOutcome !== "adopt_representative" && sessionOutcome !== "validated_range" && sessionOutcome !== "exact_minute_confirmed" && sessionOutcome !== "provisional_range" && sessionOutcome !== "provisional_range_user_stopped" && sessionOutcome !== "completed_with_range" || keepAcceptedFocus) && (!input.accepted || keepAcceptedFocus) ) { const existingChoice = parseAgentChoiceCopy(focus.expectedAnswerSchema ?? null); const reverseVerify = focus.intent === "reverse_verify"; const keepChoice = reverseVerify || (Boolean(existingChoice) && ( input.evidence.some(isConfirmedDated) || focus.intent === "out_of_sample_check" )); const liveProbe = liveDistinguishProbe( focus, input.eventProbes, input.contrastPacket?.probes, ); const acceptedKeepHint = input.accepted ? "当前排盘已采用该时间。现在按该分钟核对。" : ""; const keepNext = keepAcceptedFocus ? makeFollowup((() => { const parsedId = parsePersistedFollowupQuestionId(focus.questionId); const themeFromDomain = focus.targetDomain && focus.targetDomain in REVERSE_VERIFY_THEME ? REVERSE_VERIFY_THEME[focus.targetDomain as keyof typeof REVERSE_VERIFY_THEME] : null; const keepMethodId = ( parsedId && parsedId.method_id !== "active_focus" ? parsedId.method_id : focus.intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify" ) as MethodFollowup["method_id"]; const keepAskTheme = ( parsedId && parsedId.ask_theme !== "active_focus" ? parsedId.ask_theme : themeFromDomain ?? (focus.intent === "out_of_sample_check" ? "oos_blind" : "education_style") ) as MethodFollowup["ask_theme"]; const matchingVerify = (input.eventProbes ?? []).find((probe) => ( probe.domain === focus.targetDomain || REVERSE_VERIFY_THEME[probe.domain as keyof typeof REVERSE_VERIFY_THEME] === keepAskTheme )); const schema = focus.expectedAnswerSchema; const schemaYear = typeof schema?.probe_year === "number" && schema.probe_year > 0 ? schema.probe_year : undefined; const keepProbeYear = matchingVerify?.year || schemaYear; return { method_id: keepMethodId, intent: focus.intent || "reverse_verify", ask_theme: keepAskTheme, domain: focus.targetDomain, kind_hint: focus.targetKind, user_prompt_hint: `${acceptedKeepHint}先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。`, source: focus.intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify", ...(keepProbeYear ? { probe_year: keepProbeYear, year_label: matchingVerify?.year_label ?? `${keepProbeYear} 年前后`, } : {}), ...(existingChoice ? { style_options: existingChoice.options.map((option) => ({ label: option.label, answer_class: option.answer_class, })), } : matchingVerify?.style_options ? { style_options: matchingVerify.style_options, } : {}), }; })(), parsedIdScoring(focus.questionId), true) : makeFollowup({ method_id: "active_focus", intent: focus.intent || "active_focus", ask_theme: "active_focus", domain: parseCollectFocusQuestionId(focus.questionId)?.domain ?? focus.targetDomain, kind_hint: focus.targetKind, user_prompt_hint: keepChoice ? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。" : "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。", source: "active_focus", ...(liveProbe && focus.intent === "distinguish_candidates" ? { information_gain: liveProbe.information_gain, semantic_key: liveProbe.semantic_key, candidate_split_hash: liveProbe.candidate_split_hash, probe_year: liveProbe.year, year_label: liveProbe.year_label, probe_month: liveProbe.month, choice_kind: liveProbe.choice_kind, candidate_ids: liveProbe.candidate_ids ?? candidateIdsFromProbe(liveProbe), expected_outcomes: liveProbe.expected_outcomes, style_options: liveProbe.style_options, } : {}), }, true, keepChoice); if (!( keepChoice && focus.intent === "distinguish_candidates" && !keepNext.choice_frame )) { return { methods, next_followup: keepNext, deferred_followup: null, session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, dropped_probes: rankedCatalog.dropped, }; } } if (sessionOutcome === "validate_holdout") { const fields = holdoutFollowupFor(input, declined); return { methods, next_followup: fields ? makeFollowup(fields, false) : null, deferred_followup: null, session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, dropped_probes: rankedCatalog.dropped, }; } if (input.accepted) { const probe = remainingReverseVerifyProbes( input.eventProbes, input.evidence, reverseVerifyDeclined, askedKeys, input.birthDate, )[0] ?? null; const theme = probe ? REVERSE_VERIFY_THEME[probe.domain] : null; const next = probe && theme ? makeFollowup({ method_id: "reverse_verify", intent: "reverse_verify", ask_theme: theme, domain: probe.domain, kind_hint: REVERSE_VERIFY_KIND[probe.domain], user_prompt_hint: ask( `当前排盘已采用。按该分钟核对:${probe.year_label} 是否有${probe.event_family}。对得上写入账本并重算;对不上可以改选其他候选。不确认唯一分钟。`, REVERSE_VERIFY_VARGA[probe.domain], ), source: "reverse_verify", semantic_key: probe.semantic_key ?? `${probe.domain}.${probe.year}`, candidate_split_hash: probe.candidate_split_hash, probe_year: probe.year, year_label: probe.year_label, probe_month: probe.month, choice_kind: probe.choice_kind, candidate_ids: probe.candidate_ids ?? candidateIdsFromProbe(probe), expected_outcomes: probe.expected_outcomes, style_options: probe.style_options, information_gain: probe.information_gain ?? 0, probe_id: probe.semantic_key, }, true, true) : null; return { methods, next_followup: next, deferred_followup: null, session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, dropped_probes: rankedCatalog.dropped, }; } let next: MethodFollowup | null = null; const extraDropped: DroppedProbe[] = []; const stage = input.precisionStage ?? null; const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => { if (ranked.kind === "event" && ranked.eventProbe) { const conflictProbe = ranked.eventProbe; const nearby = nearbyLedgerHint( input.evidence, conflictProbe.domain, conflictProbe.year, conflictProbe.month, ); return makeFollowup({ method_id: PROBE_METHOD_ID[conflictProbe.domain], intent: "distinguish_candidates", ask_theme: REVERSE_VERIFY_THEME[conflictProbe.domain], domain: conflictProbe.domain, kind_hint: REVERSE_VERIFY_KIND[conflictProbe.domain], user_prompt_hint: ask( `当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`, REVERSE_VERIFY_VARGA[conflictProbe.domain], nearby, ), source: "event_probe", information_gain: conflictProbe.information_gain ?? 0, semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`, candidate_split_hash: conflictProbe.candidate_split_hash, probe_year: conflictProbe.year, year_label: conflictProbe.year_label, probe_month: conflictProbe.month, choice_kind: conflictProbe.choice_kind ?? "existence", candidate_ids: conflictProbe.candidate_ids ?? candidateIdsFromProbe(conflictProbe), expected_outcomes: conflictProbe.expected_outcomes, style_options: ranked.styleOptions, selection_score: ranked.score, probe_id: conflictProbe.semantic_key, }, true, true); } const contrast = ranked.contrastProbe!; const domain = contrastFollowupDomain(contrast.domain); const expectedOutcomes = contrast.expectedOutcomes.map((row) => ({ answer_class: row.outcomeId, supports: row.supportsCandidateIds, conflicts: row.conflictsCandidateIds, })); const vargaAnchor = contrast.choiceKind === "varga_style" ? datedLedgerAnchor(input.evidence, domain) : null; const nearby = nearbyLedgerHint(input.evidence, domain, contrast.year, null); const extra = vargaAnchor ? `题干先提到 ${vargaAnchor.label} 这段经历,再问风格。` : nearby || (contrast.authoringHint ?? "按候选盘面差异核对前事,不要问两套盘哪个更像。"); return makeFollowup({ method_id: PROBE_METHOD_ID[domain], intent: "distinguish_candidates", ask_theme: REVERSE_VERIFY_THEME[domain], domain, kind_hint: REVERSE_VERIFY_KIND[domain], user_prompt_hint: ask( contrast.question, REVERSE_VERIFY_VARGA[domain], extra, ), source: "event_probe", information_gain: contrast.informationGain, semantic_key: contrast.semanticKey, candidate_split_hash: contrast.candidateSplitHash, probe_year: contrast.year ?? undefined, choice_kind: contrast.choiceKind ?? "existence", candidate_ids: [...new Set(contrast.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))], expected_outcomes: expectedOutcomes, style_options: ranked.styleOptions, selection_score: ranked.score, probe_id: contrast.probeId, }, true, true); }; if (dashaCovered) { const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated; for (const ranked of rankedDiscriminators) { if (!allowLowGainDiscriminator && ranked.score < 0.08) continue; const candidate = followupFromRanked(ranked); if (candidate.choice_frame) { next = candidate; break; } } } // BUG-651: personality / nakshatra stay optional after range delivery. // They never occupy the discriminator slot, even when the dated pool is empty. for (const ranked of personalityDiscriminators) { extraDropped.push(droppedFromProbe( ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style", ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score, "yearless_deferred", )); } if (input.nakshatraProbe) { extraDropped.push(droppedFromProbe( input.nakshatraProbe.semantic_key, input.nakshatraProbe.information_gain, "yearless_deferred", )); } if (!next && !dashaCovered) { next = makeFollowup({ method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: OPENING_COLLECT_DOMAIN, kind_hint: null, user_prompt_hint: collect( "可以先从最容易想起的一件带大概时间的经历开始。", "本命 Dasha + 行运(方法1)", "不要求一次列出 10–15 条。", ), source: "method_coverage", }); } const collectingByPool = !meetsAcceptanceEventQuality(input.evidence); if (!next && dashaCovered && collectingByPool) { const poolNext = nextCollectionFollowup(input.evidence, [ ...(input.declinedTopics ?? []), ...(input.closedCollectFocuses ?? []), ]); if (poolNext) next = makeFollowup(poolNext); } // S1 ranks invite / anchors / generics. Do not convert leftover yearless // contrast back into a domain wheel after the training gate (BUG-648). // BUG-651: nakshatra is already deferred above and must not take this slot. const sameDomainYearlessCard = (domain: string): MethodFollowup | null => { const ranked = yearlessDiscriminators.find((row) => ( (row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain )); if (!ranked) return null; const candidate = followupFromRanked(ranked); return candidate.choice_frame ? candidate : null; }; const firstRenderableYearlessFollowup = (): MethodFollowup | null => { for (const ranked of yearlessDiscriminators) { const key = rankedDiscriminatorKey(ranked); if (key && askedKeys.has(key)) continue; const candidate = followupFromRanked(ranked); if (candidate.choice_frame) return candidate; } return null; }; const precisionStageFollowup = (): MethodFollowup | null => { if (stage === "lagna_frame") { return makeFollowup({ method_id: "dasha_events", intent: "distinguish_candidates", ask_theme: "dated_event", domain: null, kind_hint: null, user_prompt_hint: ask( "窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。", "本命上升 / Dasha", ), source: "precision_stage", }); } if (stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) { return makeFollowup({ method_id: "d9_relationship", intent: "distinguish_candidates", ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_change", user_prompt_hint: ask( "关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D9", "对照 D9 上升类型表只作校时方法。", ), source: "precision_stage", }); } if (stage === "d10_refine" && !declined.has("career")) { return makeFollowup({ method_id: "d10_career", intent: "distinguish_candidates", ask_theme: "career_style", domain: "career", kind_hint: "career_change", user_prompt_hint: ask( "事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D10", "可用 D10 事业类型表作校时对照。", ), source: "precision_stage", }); } if ((stage === "d4_refine" || stage === "theme_refine") && !declined.has("relocation")) { return makeFollowup({ method_id: "d4_home", intent: "distinguish_candidates", ask_theme: "home_change", domain: "relocation", kind_hint: "home_change", user_prompt_hint: ask( "居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D4", ), source: "precision_stage", }); } if (stage === "d5_refine" && !declined.has("education")) { return makeFollowup({ method_id: "d5_education", intent: "distinguish_candidates", ask_theme: "education_style", domain: "education", kind_hint: "education_milestone", user_prompt_hint: ask( "成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D5 / D24", ), source: "precision_stage", }); } return null; }; let renderableYearless: MethodFollowup | null = null; const takeRenderableDistinguish = (item: MethodFollowup | null): MethodFollowup | null => { if (!item) return null; if (item.intent !== "distinguish_candidates" || item.choice_frame) return item; extraDropped.push({ semantic_key: item.semantic_key ?? item.method_id, information_gain: item.information_gain ?? 0, reason: "frameless_distinguish", }); return null; }; const pendingHoldout = input.holdoutValidation === "not_started" ? holdoutFollowupFor(input, declined) : null; if (!next) { const precisionCard = takeRenderableDistinguish(precisionStageFollowup()); const datedPoolEmpty = rankedDiscriminators.length === 0; const datedPrecision = Boolean( precisionCard?.choice_frame && followupLocksDatedPeriod(precisionCard), ); if (collectingByPool) { next = null; } else if (datedPrecision) { next = precisionCard; } else if ( datedPoolEmpty && meetsAcceptanceEventQuality(input.evidence) && sessionOutcome === "discriminate_candidates" ) { // BUG-651: discriminating with no dated probe must not pick yearless // personality or leftover method collect as the next discriminator. next = null; } else if ((renderableYearless = firstRenderableYearlessFollowup())) { next = renderableYearless; } else if ( // BUG-547: a renderable precision card beats dated collect, but only // after the training gate is open. lagna_frame can borrow an unrelated // probe into choice_frame while holdout is still short. meetsAcceptanceEventQuality(input.evidence) && precisionCard?.choice_frame ) { next = precisionCard; } else if (!occupationCovered && 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"); } } else if (pendingHoldout) { next = makeFollowup(pendingHoldout, false); } else if ( !meetsAcceptanceEventQuality(input.evidence) && !(stage === "d5_refine" && !hasConfirmedDomain(input.evidence, "education") && !declined.has("education")) && !(stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) && !(stage === "d10_refine" && !careerCovered && !declined.has("career")) && !((stage === "d4_refine" || stage === "theme_refine") && !hasConfirmedDomain(input.evidence, "relocation") && !declined.has("relocation")) ) { next = null; } else if (precisionCard) { next = precisionCard; } else { const d9 = input.observations?.find((item) => item.layer === "d9"); const d10 = input.observations?.find((item) => item.layer === "d10"); const d4 = input.observations?.find((item) => item.layer === "d4"); const d5 = input.observations?.find((item) => item.layer === "d5"); const d7 = input.observations?.find((item) => item.layer === "d7"); const d12 = input.observations?.find((item) => item.layer === "d12"); const d11 = input.observations?.find((item) => item.layer === "d11"); const d30 = input.observations?.find((item) => item.layer === "d30"); if (d9?.candidates_differ && !relationshipCovered && !declined.has("relationship")) { next = makeFollowup({ method_id: "d9_relationship", intent: "distinguish_candidates", ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_change", user_prompt_hint: ask( "当前候选在关系主题上仍分不开。按探针年份问感情前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D9", "对照 D9 上升类型表只作校时方法。", ), source: "varga_observation", }); } else if (d10?.candidates_differ && !declined.has("career")) { next = makeFollowup({ method_id: "d10_career", intent: "distinguish_candidates", ask_theme: "career_style", domain: "career", kind_hint: "career_change", user_prompt_hint: ask( "当前候选在事业主题上仍分不开。按探针年份问事业前事是否发生,用来筛窗。不要问两套盘哪个更像。", "D10", "可用事业类型表作校时对照。", ), source: "varga_observation", }); } else if (d4?.candidates_differ && !declined.has("relocation")) { next = makeFollowup({ method_id: "d4_home", intent: "distinguish_candidates", ask_theme: "home_change", domain: "relocation", kind_hint: "home_change", user_prompt_hint: ask( "当前候选在居所主题上仍分不开。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D4", ), source: "varga_observation", }); } else if (d5?.candidates_differ && !declined.has("education")) { next = makeFollowup({ method_id: "d5_education", intent: "distinguish_candidates", ask_theme: "education_style", domain: "education", kind_hint: "education_milestone", user_prompt_hint: ask( "当前候选在学业或成就主题上仍分不开。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D5 / D24", ), source: "varga_observation", }); } else if ((d12?.candidates_differ || d7?.candidates_differ) && !declined.has("family")) { next = makeFollowup({ method_id: "relatives", intent: "distinguish_candidates", ask_theme: "family_event", domain: "family", kind_hint: "family_event", user_prompt_hint: ask( "当前候选在家人主题上仍分不开。按探针年份问家人变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D12 / D7 / D3", ), source: "varga_observation", }); } else if (d11?.candidates_differ && financeCovered && !declined.has("finance")) { next = makeFollowup({ method_id: "d2_finance", intent: "distinguish_candidates", ask_theme: "finance_change", domain: "finance", kind_hint: "finance_change", user_prompt_hint: ask( "当前候选在财务主题上仍分不开。按探针年份问财务变化是否发生,用来筛窗。不要问两套盘哪个更像。", "D2 / D11", ), source: "varga_observation", }); } else if (d30?.candidates_differ && healthCovered && !declinedHealth(declined)) { next = makeFollowup({ method_id: "d30_health", intent: "distinguish_candidates", ask_theme: "health_pressure", domain: "health_pressure", kind_hint: "self_health_event", user_prompt_hint: ask( "当前候选在健康压力主题上仍分不开。按探针年份问健康或压力变化是否发生,用来筛窗。这不是医学判断。不要问两套盘哪个更像。", "D30", ), source: "varga_observation", }); } else if (horaryStatus === "uncovered") { next = sameDomainYearlessCard("horary") ?? makeFollowup({ method_id: "horary", intent: "collect_method_evidence", ask_theme: "horary", domain: "horary", kind_hint: "horary_query", user_prompt_hint: collect( "有没有第一次认真问起这件事的时间?", "占问观察盘", "有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。", ), source: "method_coverage", }); } } } if (next?.intent === "distinguish_candidates" && !next.choice_frame) { extraDropped.push({ semantic_key: next.semantic_key ?? next.method_id, information_gain: next.information_gain ?? 0, reason: next.source === "nakshatra_boundary" ? "not_renderable" : "frameless_distinguish", }); next = null; } if (!next) { const leftoverCollect = exhaustionSpokenCollectFollowup({ evidence: input.evidence, declinedTopics: [ ...(input.declinedTopics ?? []), ...(input.closedCollectFocuses ?? []), ], answeredProbes: input.answeredProbes, eventProbes: input.eventProbes, }); if (leftoverCollect) { next = makeFollowup(leftoverCollect); } else if (pendingHoldout) { next = makeFollowup(pendingHoldout, false); } } if (input.caseStage === "block_scan" && sessionOutcome === "compare_blocks") { next = blockChoiceFollowup(input.blockScan); } if (sessionOutcome === "widen_window" && input.reportedTime && input.candidateRange) { next = widenWindowFollowup({ reportedTime: input.reportedTime, currentWindow: input.candidateRange, }); } const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "validated_range" || sessionOutcome === "exact_minute_confirmed" || sessionOutcome === "provisional_range_user_stopped" || sessionOutcome === "completed_with_range"; const deferProvisionalDiscriminator = sessionOutcome === "provisional_range" && next?.intent === "distinguish_candidates" && Boolean(next.choice_frame); const keepEvidenceCollect = isRemainingEvidenceCollect(next); const deferFollowup = (deferAdoption || deferProvisionalDiscriminator) && !keepEvidenceCollect; const deferred = deferFollowup ? next : null; return { methods, next_followup: deferFollowup ? null : next, // Adopt may stash a collect for later. Frameless distinguish must not // sit in deferred_followup or it bypasses the adopt early-exit (BUG-519). deferred_followup: ( deferred?.intent === "distinguish_candidates" && !deferred.choice_frame ? null : deferred ), session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, dropped_probes: [...rankedCatalog.dropped, ...extraDropped], }; } export function projectRectificationChoiceCard( input: Parameters[0] & { selectionAllowed?: boolean; proposeAllowed?: boolean; confirmationAllowed?: boolean; userStopped?: boolean; candidateScores?: readonly Readonly<{ time: string; score: number }>[]; caseRevision?: number | null; latestAssistantText?: string | null; }, ): RectificationChoiceCard | null { const sessionOutcome = input.sessionOutcome; const plan = buildMethodFollowupPlan(input); if ( !input.accepted && (sessionOutcome === "adopt_representative" || sessionOutcome === "awaiting_confirmation" || sessionOutcome === "validated_range" || sessionOutcome === "exact_minute_confirmed" || sessionOutcome === "provisional_range" || sessionOutcome === "provisional_range_user_stopped" || sessionOutcome === "completed_with_range") ) { return null; } const focusId = input.activeFocus && "id" in input.activeFocus && typeof input.activeFocus.id === "string" ? input.activeFocus.id.trim() : ""; if (!isPersistedFocusId(focusId)) return null; const schema = input.activeFocus?.expectedAnswerSchema ?? null; const schemaCopy = parseAgentChoiceCopy(schema); const intent = input.activeFocus?.intent ?? ""; const verifyOnly = intent === "reverse_verify" || intent === "out_of_sample_check"; // Recast can drop choice_frame when the schema has no probe_year. The open // A–D schema is still the live question; GET must not silence it. const persistedVerifyCard = (): RectificationChoiceCard | null => { if (!verifyOnly || !schemaCopy) return null; const parsed = parsePersistedFollowupQuestionId(input.activeFocus?.questionId); const questionId = input.activeFocus?.questionId?.trim() ?? ""; const probeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string" ? (schema as { probe_id: string }).probe_id : null; return choiceCardFromPersistedVerifyCopy({ copy: { ...schemaCopy, prompt: overlayChoicePromptFromSpoken(schemaCopy.prompt, input.latestAssistantText), }, questionId, methodId: parsed?.method_id ?? (intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify"), scoring: parsed?.scoring !== false, probeId, caseRevision: input.caseRevision ?? null, focusId, }); }; const followup = plan.next_followup ?? plan.deferred_followup ?? null; if (!followup) return persistedVerifyCard(); const frame = followup.choice_frame; if (!frame) return persistedVerifyCard(); const schemaRow = schema && typeof schema === "object" && !Array.isArray(schema) ? schema as Record : null; if (input.activeFocus?.intent !== followup.intent) return null; if (followup.semantic_key && schemaRow?.semantic_key !== followup.semantic_key) return null; if ( followup.candidate_split_hash && !isSameCandidateSplit( typeof schemaRow?.candidate_split_hash === "string" ? schemaRow.candidate_split_hash : null, followup.candidate_split_hash, followup.semantic_key, ) ) return null; if ( !followup.semantic_key && !followup.candidate_split_hash && input.activeFocus?.questionId && input.activeFocus.questionId !== frame.question_id ) return null; const probeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string" ? (schema as { probe_id: string }).probe_id : null; const copy = parseAgentChoiceCopy(schema); const overlaid = copy ? { ...copy, prompt: overlayChoicePromptFromSpoken( preferConcreteChoicePrompt(frame.prompt, copy.prompt), input.latestAssistantText, ), } : null; return mergeChoiceCard(frame, overlaid, { question_id: input.activeFocus?.questionId ?? frame.question_id, probe_id: probeId, case_revision: input.caseRevision ?? null, focus_id: focusId, }); }