/** * 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 stays out of domain rotation and is only asked at d4_refine. * Finance/health score if volunteered; they are not method-layer rotation. * Method coverage finishes before repeating a precision-stage ask. * Appearance and marks are skipped_by_policy. Horary does not block offering * time cards. Occupation does block cards until a note exists. * 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. * 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 chart discriminators (D9/D10 style and dated dasha * probes) stay in the pool; the highest information-gain renderable * probe is asked next, with no preferred domain. * 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. A yearless D12/family contrast does not attach * A/B/C/D; skip it and either ask the next dated discriminator or collect a * dated family event in natural language. */ import { buildChoiceFrame, isPersistedFocusId, parseAgentChoiceCopy, preferConcreteChoicePrompt, mergeChoiceCard, type RectificationChoiceCard, type RectificationChoiceFrame, } from "./choice-card.ts"; import { overlayChoicePromptFromSpoken } from "./turn-narration.ts"; import { decideRectification, type HoldoutValidationStatus } from "../core/rectification-decision.ts"; import { askedKeysFromLedgerEvidence, datedDomainsFromEvidence, isStructuredDiscriminator, selectDiscriminatorProbe, type CandidateContrastPacket, type CandidateDiscriminatorProbe, } from "../core/candidate-contrast-packet.ts"; import { candidateIdsFromProbe, isValidDistinguishProbe } from "../core/distinguish-contract.ts"; import { completeStyleOptions, isRenderableProbe, rankDiscriminatorScore, } from "./probe-question-contract.ts"; import type { SessionOutcomeKind } from "./confirmation-gate.ts"; import { meetsAcceptanceEventQuality, trainingScoreableGate } from "./evidence-model"; import type { DiscriminatingEventProbe, EventProbeDomain, EventProbeStyleOption, NakshatraBoundary, OosBlindPrompt, PrecisionStageId, } from "./refinement-packet"; import { EVENT_PROBE_DOMAINS } from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; 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"; 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"; 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"; 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; }>; 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"]; }>; export type MethodFollowupEvidence = Readonly<{ status: string; domain: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null; eventKind?: string | null; id?: string; summary?: 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", "occupation", ]); function isConfirmedDated(item: MethodFollowupEvidence): 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); } 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 DOMAIN_AGE_LO: Readonly> = { education: 16, relocation: 18, relationship: 21, career: 22, finance: 22, health_pressure: 16, }; 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; } function birthYearFromDate(birthDate: string | null | undefined): number | null { if (!birthDate || birthDate.length < 4 || !/^\d{4}/.test(birthDate)) return null; const year = Number(birthDate.slice(0, 4)); return year >= 1900 && year <= 2100 ? year : null; } function probeBelowAdultFloor( probe: Pick, birthDate?: string | null, ): boolean { const birthYear = birthYearFromDate(birthDate); const floor = DOMAIN_AGE_LO[probe.domain]; if (birthYear === null || floor == null || !probe.year) return false; return probe.year < birthYear + floor; } 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"); } function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean { return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health"); } function declinedHealth(declined: Set): boolean { return declined.has("health") || declined.has("health_pressure"); } function declinedDomains( topics: readonly Readonly>[], ): Set { const domains = new Set(); for (const topic of topics) { const domain = typeof topic.target_domain === "string" ? topic.target_domain : typeof topic.targetDomain === "string" ? topic.targetDomain : null; if (domain) domains.add(domain); } return domains; } 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 choiceKind = probe.choiceKind ?? "existence"; const styleOptions = completeStyleOptions({ choiceKind, styleOptions: probe.styleOptions?.map((item) => ({ label: item.label, answer_class: item.answerClass, ...(item.sign ? { sign: item.sign } : {}), })), }); if (!styleOptions) return null; const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; return { year: probe.year ?? 0, year_label: probe.year ? `${probe.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: probe.question, role: "distinguish", phase: "candidate_discriminator", information_gain: probe.informationGain, semantic_key: probe.semanticKey, candidate_split_hash: probe.candidateSplitHash, candidate_ids: candidateIds, expected_outcomes: probe.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 remainingReverseVerifyProbes( probes: readonly DiscriminatingEventProbe[] | undefined, evidence: readonly MethodFollowupEvidence[], declined: ReadonlySet, birthDate?: string | null, ): DiscriminatingEventProbe[] { const dasha: DiscriminatingEventProbe[] = []; const fallback: DiscriminatingEventProbe[] = []; for (const probe of probes ?? []) { if (probe.source === "known_event_quality" || probe.role === "clarify" || probe.phase === "event_clarification") continue; if (probe.role === "collect" || probe.phase === "evidence_collection") continue; if (declined.has(probe.domain)) continue; if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue; if (probeBelowAdultFloor(probe, birthDate)) continue; if (CONFLICT_PROBE_SOURCES.has(probe.source)) { dasha.push(probe); } else { fallback.push(probe); } } return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY); } 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" | "year_label" | "probe_month" | "semantic_key" > { if (!probe) return {}; return { probe_year: probe.year, year_label: probe.year_label, ...(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))) 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: NonNullable>; }>; function renderableEventProbe( probe: DiscriminatingEventProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], ): RankedDiscriminator | null { const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe); const styleOptions = completeStyleOptions({ choiceKind: probe.choice_kind, styleOptions: probe.style_options, }); if (!styleOptions || !isValidDistinguishProbe({ ...probe, role: "distinguish" })) return null; if (!isRenderableProbe({ informationGain: probe.information_gain, candidateIds, expectedOutcomeCount: probe.expected_outcomes?.length, choiceKind: probe.choice_kind, styleOptions, })) return null; const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)); return { kind: "event", eventProbe: probe, styleOptions, score: rankDiscriminatorScore({ informationGain: probe.information_gain ?? 0, asked, candidateIds, topCandidateTimes, }), }; } function renderableContrastProbe( probe: CandidateDiscriminatorProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], ): RankedDiscriminator | null { const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, ...row.conflictsCandidateIds, ]))]; const styleOptions = completeStyleOptions({ choiceKind: probe.choiceKind, styleOptions: probe.styleOptions?.map((item) => ({ label: item.label, answer_class: item.answerClass, ...(item.sign ? { sign: item.sign } : {}), })), }); if (!styleOptions || !isRenderableProbe({ informationGain: probe.informationGain, candidateIds, expectedOutcomeCount: probe.expectedOutcomes.length, choiceKind: probe.choiceKind, styleOptions, })) return null; const asked = askedKeys.has(probe.semanticKey) || askedKeys.has(probe.candidateSplitHash) || askedKeys.has(probe.probeId); return { kind: "contrast", contrastProbe: probe, styleOptions, score: rankDiscriminatorScore({ informationGain: probe.informationGain, asked, candidateIds, topCandidateTimes, }), }; } 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: "生病、受伤或压力特别大", }; const YEARLESS_COLLECT_LEAD: 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"; 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 true; const year = row.eventProbe?.year ?? row.contrastProbe?.year ?? 0; return year > 0; } function rankRenderableDiscriminators(input: { eventProbes: readonly DiscriminatingEventProbe[]; contrastProbes: readonly CandidateDiscriminatorProbe[]; askedKeys: ReadonlySet; topCandidateTimes?: readonly string[]; providedDomains?: readonly string[]; evidence?: readonly MethodFollowupEvidence[]; }): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[] } { const top = input.topCandidateTimes ?? []; const provided = new Set(input.providedDomains ?? []); const rows: RankedDiscriminator[] = []; const seen = new Set(); const push = (row: RankedDiscriminator | null) => { 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)); } for (const probe of input.contrastProbes) { if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) continue; push(renderableContrastProbe(probe, input.askedKeys, top)); } 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)), yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && discriminatorChoiceKind(row) !== "varga_style"), }; } 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)}用自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.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。用简体中文自己写一句追问;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。不要调用 set-focus。正文不要复述选项。`.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 true; 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; } const USER_COLLECT_QUESTION: Readonly> = { relationship: "还记得别的带年份的感情变化吗?比如开始认真交往、分手或结婚。", career: "还记得别的带年份的工作变化吗?比如入职、换工作或职责加重。", family: "家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。", occupation: "你长期做什么工作?", education: "有没有记得住年份的升学、转学或考试?", relocation: "有没有记得住时间的搬家或长期住到外地?", }; export function spokenFollowupForUser(followup: MethodFollowup | null): string | null { if (!followup) return null; if (followup.choice_frame) return "接下来请点选下面这一问。"; if (followup.intent !== "collect_method_evidence") return null; const base = USER_COLLECT_QUESTION[followup.domain ?? ""] ?? "请再说一件记得大概时间的经历。"; const period = followup.year_label ?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null); return period ? `${period},${base}` : base; } export type NextUserActionId = | "adopt_representative" | "score_now" | "record_stated_events" | "ask_method_followup" | "ask_candidate_discriminator" | "ask_holdout_validation" | "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 === "event_quality" || followup.intent === "clarify_event") 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; }): 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, }); } 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", "当前几个候选基本并列。说明这是可信区间,代表分钟只是计算用的代表点,不要称某分钟为当前推荐;不要继续假装已经收敛。", ); 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" || input.sessionOutcome === "completed_with_range" || input.sessionOutcome === "provisional_range_user_stopped") { return { id: provisional.id, user_meaning: provisional.user_meaning, on_user_stop: provisional }; } 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}。候选还没拉开。用户说没有更多时,给出并列可信区间,不要宣布某分钟胜出。`, ), }; } 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 }; } export function buildMethodFollowupPlan(input: { evidence: readonly MethodFollowupEvidence[]; activeFocus?: MethodFollowupFocus | null; declinedTopics?: readonly Readonly>[]; observations?: readonly InternalVargaObservation[]; sessionOutcome?: SessionOutcomeKind; precisionStage?: PrecisionStageId | null; nakshatraBoundary?: NakshatraBoundary | null; oosBlindPrompts?: readonly OosBlindPrompt[]; eventProbes?: readonly DiscriminatingEventProbe[]; eventClarificationProbes?: readonly DiscriminatingEventProbe[]; evidenceCollectionProbes?: readonly DiscriminatingEventProbe[]; askedProbeKeys?: readonly string[]; birthDate?: string | null; accepted?: boolean; candidatesSeparated?: boolean; contrastPacket?: CandidateContrastPacket | null; topCandidateTimes?: readonly string[]; holdoutValidation?: HoldoutValidationStatus; holdoutEvents?: readonly Readonly<{ domain: string; year: number | 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); return { ...base, choice_frame: attach ? buildChoiceFrame(base, { observations: input.observations, evidence: input.evidence, probes: [ ...(ownedProbe ? [ownedProbe] : []), ...(input.eventProbes ?? []), ...(input.eventClarificationProbes ?? []), ...(input.evidenceCollectionProbes ?? []), ], birthDate: input.birthDate, scoring, }) : 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 dashaCovered = input.evidence.some(isConfirmedDated); const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship"); const careerCovered = hasConfirmedDomain(input.evidence, "career"); const familyCovered = hasConfirmedDomain(input.evidence, "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"); 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 ?? "collect_evidence"; 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 askedKeys = new Set([ ...(input.askedProbeKeys ?? []), ...askedKeysFromLedgerEvidence(input.evidence), ]); const rankedCatalog = dashaCovered && meetsAcceptanceEventQuality(input.evidence) ? rankRenderableDiscriminators({ eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys, input.birthDate), contrastProbes, askedKeys, topCandidateTimes: input.topCandidateTimes, providedDomains: datedDomainsFromEvidence(input.evidence), evidence: input.evidence, }) : { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[] }; const rankedDiscriminators = rankedCatalog.locked; 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") ), ); const staleDiscriminatorFocus = Boolean( focus && focus.intent === "distinguish_candidates" && catalogWinnerKey && persistedFocusProbeKey(focus) && persistedFocusProbeKey(focus) !== catalogWinnerKey ); 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 keepNext = makeFollowup({ method_id: "active_focus", intent: focus.intent || "active_focus", ask_theme: "active_focus", domain: focus.targetDomain, kind_hint: focus.targetKind, user_prompt_hint: keepChoice ? "先承接当前焦点。自己写一句追问;年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。不要调用 set-focus。正文不要复述选项。" : "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则继续用自然语言问一件带大概年份的事。不要写 expectedAnswerSchema.choice。", 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, stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, }; } } if (sessionOutcome === "validate_holdout") { const prompt = input.oosBlindPrompts?.[0] ?? null; const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null; const holdoutNext = prompt ? makeFollowup({ method_id: "oos_blind", intent: "out_of_sample_check", ask_theme: "holdout", domain: prompt.domain, kind_hint: null, user_prompt_hint: prompt.user_meaning, source: "oos_blind", }, false, true) : reserved ? makeFollowup({ method_id: "holdout_validation", intent: "out_of_sample_check", ask_theme: "holdout", domain: reserved.domain, kind_hint: null, user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`, source: "oos_blind", }, false, true) : null; return { methods, next_followup: holdoutNext, deferred_followup: null, session_outcome: sessionOutcome, stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, }; } if (input.accepted) { const probe = remainingReverseVerifyProbes(input.eventProbes, input.evidence, declined, 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", }, true, true) : null; return { methods, next_followup: next, deferred_followup: null, session_outcome: sessionOutcome, stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, }; } let next: MethodFollowup | null = null; const stage = input.precisionStage ?? null; const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => { if (ranked.kind === "event" && ranked.eventProbe) { const conflictProbe = ranked.eventProbe; 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], ), 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, })); 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], "按候选盘面差异核对前事,不要问两套盘哪个更像。", ), 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) { next = makeFollowup({ method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: null, kind_hint: null, user_prompt_hint: collect( "可以先从最容易想起的一件带大概时间的经历开始。", "本命 Dasha + 行运(方法1)", "不要求一次列出 10–15 条。", ), source: "method_coverage", }); } else { 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; } } if (!next && coverageComplete) { const yearless = yearlessDiscriminators[0]; if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) { const domain = contrastFollowupDomain( yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null, ); const lead = YEARLESS_COLLECT_LEAD[domain]; if (lead && !declined.has(domain)) { next = makeFollowup({ method_id: PROBE_METHOD_ID[domain], intent: "collect_method_evidence", ask_theme: REVERSE_VERIFY_THEME[domain], domain, kind_hint: REVERSE_VERIFY_KIND[domain], user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]), source: "method_coverage", }); } } } } if (!next) { if (!relationshipCovered && !declined.has("relationship")) { next = makeFollowup({ method_id: "d9_relationship", intent: "collect_method_evidence", ask_theme: "relationship_style", domain: "relationship", kind_hint: "relationship_start", user_prompt_hint: collect( "可以先说一段记得大概时间的感情变化,比如开始认真交往、分手或结婚。", "D9", "对照 D9 上升类型表(白羊主动热情、天蝎深刻占有等)只作校时方法,不是命运承诺。", ), source: "method_coverage", }); } else if (!careerCovered && !declined.has("career")) { next = makeFollowup({ method_id: "d10_career", intent: "collect_method_evidence", ask_theme: "career_style", domain: "career", kind_hint: "career_entry", user_prompt_hint: collect( "可以先说一段记得大概时间的工作变化,比如入职、换工作或职责加重。同一件事会同时对照本命第 10 宫和 D10。", "D10", "可用 D10 事业类型表作校时对照。", ), source: "method_coverage", }); } else if (!familyCovered && !declined.has("family")) { const familyCollect = datedCollectionProbe(input.evidenceCollectionProbes, "family"); next = makeFollowup({ method_id: "relatives", intent: "collect_method_evidence", ask_theme: "family_event", domain: "family", kind_hint: "family_event", user_prompt_hint: collect( "可以先问家里有没有结婚、添丁或住院这类记得住时间的事。同一件事会对照 D12 父母盘、D7 子女盘和 D3 兄弟盘。", "D12 / D7 / D3", "六亲用 Raman 六步:前三步可执行,后三步标方法论层。", ), source: "method_coverage", ...collectionYearFields(familyCollect), }); } else if (!occupationCovered) { next = makeFollowup({ method_id: "occupation", intent: "collect_method_evidence", ask_theme: "occupation", domain: "occupation", kind_hint: "occupation_note", user_prompt_hint: collect( "你长期做什么工作?对照本命第 10 宫和 D10。", "D1-H10 + D10", "可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。", ), source: "method_coverage", }); } else if ( !meetsAcceptanceEventQuality(input.evidence) && !(stage === "d5_refine" && !hasConfirmedDomain(input.evidence, "education") && !declined.has("education")) && !(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 = makeFollowup({ method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: null, kind_hint: null, user_prompt_hint: collect( "再记一件记得大概时间的经历。当前用来区分候选的训练事件还不够。", "本命 Dasha + 行运(方法1)", "不要开始点选区分题。", ), source: "method_coverage", }); } else if (stage === "lagna_frame") { next = 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", }); } else if (stage === "d9_refine" && !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: "precision_stage", }); } else if (stage === "d10_refine" && !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", "可用 D10 事业类型表作校时对照。", ), source: "precision_stage", }); } else if ((stage === "d4_refine" || stage === "theme_refine") && !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: "precision_stage", }); } else if (stage === "d5_refine" && !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: "precision_stage", }); } 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 (input.nakshatraBoundary?.near_boundary) { next = makeFollowup({ method_id: "nakshatra_boundary", intent: "distinguish_candidates", ask_theme: "nakshatra_trait", domain: null, kind_hint: null, user_prompt_hint: input.nakshatraBoundary.user_meaning ?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。", source: "nakshatra_boundary", }); } else if (input.holdoutValidation === "not_started") { const prompt = input.oosBlindPrompts?.[0]; const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null); if (prompt) { next = makeFollowup({ method_id: "oos_blind", intent: "out_of_sample_check", ask_theme: "holdout", domain: prompt.domain, kind_hint: null, user_prompt_hint: prompt.user_meaning, source: "oos_blind", }, false, true); } else if (reserved) { next = makeFollowup({ method_id: "holdout_validation", intent: "out_of_sample_check", ask_theme: "holdout", domain: reserved.domain, kind_hint: null, user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`, source: "oos_blind", }, false, true); } } else if (horaryStatus === "uncovered") { next = 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", }); } } } const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "validated_range" || sessionOutcome === "exact_minute_confirmed" || sessionOutcome === "provisional_range" || sessionOutcome === "provisional_range_user_stopped" || sessionOutcome === "completed_with_range"; return { methods, next_followup: deferAdoption ? null : next, deferred_followup: deferAdoption ? next : null, session_outcome: sessionOutcome, stop_domain_rotation: true, do_not_poll: DO_NOT_POLL, not_in_rotation: NOT_IN_ROTATION, }; } 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 { let plan = buildMethodFollowupPlan(input); const sessionOutcome = conversationalSessionOutcome({ selectionAllowed: input.selectionAllowed === true, proposeAllowed: input.proposeAllowed === true, confirmationAllowed: input.confirmationAllowed === true, nextFollowup: plan.next_followup, methods: plan.methods, userStopped: input.userStopped, candidateScores: input.candidateScores, discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null) ?? undefined, holdoutValidation: input.holdoutValidation, evidence: input.evidence, }); if (sessionOutcome !== (input.sessionOutcome ?? "collect_evidence")) { plan = buildMethodFollowupPlan({ ...input, sessionOutcome }); } 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 followup = plan.next_followup ?? plan.deferred_followup ?? null; if (!followup) return null; const frame = followup.choice_frame; if (!frame) return null; const schema = input.activeFocus?.expectedAnswerSchema ?? null; 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 && schemaRow?.candidate_split_hash !== followup.candidate_split_hash) 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, }); }