Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/method-followup.ts
T
Jesse_Chen 4e0db55f03 fix(rectification): keep compare requests valid after style cards (BUG-577–580)
Engine asked_probe_keys no longer include varga split hashes that 400 the scorer, failed compares become visible and retry, user stop can still deliver a range on a stale snapshot, and holdout no longer reasks domains already in the ledger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:46:33 +08:00

2745 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 dated collect and occupation
* before the adopt early-exit. Yearless-to-collect conversion still waits
* until occupation is covered. 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 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. 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,
} from "../user-copy.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,
EXISTENCE_STYLE_OPTIONS,
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 {
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 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;
block_periods?: Readonly<Record<"A" | "B" | "C", BlockScanBlock>>;
widen_windows?: Readonly<Record<"A" | "B", import("./window-widen.ts").WidenWindowOption>>;
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<Record<string, unknown>> | null;
}>;
const DO_NOT_POLL = [] as const;
const NOT_IN_ROTATION = ["relocation"] as const;
const BLOCKING_COVERAGE_IDS = new Set<MethodFollowupId>([
"dasha_events",
"d9_relationship",
"d10_career",
"relatives",
]);
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);
}
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<string> {
const domains = new Set<string>();
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<Record<string, number>> = {
education: 1,
relationship: 1,
career: 1,
relocation: 1,
};
const DOMAIN_AGE_LO: Readonly<Record<string, number>> = {
education: 16,
relocation: 18,
relationship: 21,
career: 22,
finance: 22,
health_pressure: 16,
};
const RECORDED_KIND_LABEL: Readonly<Record<string, string>> = {
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;
}
const SEMANTIC_YEAR = /^([a-z_]+)\.((?:19|20)\d{2})(?:\.|$)/;
export function existenceProbeAsked(
askedKeys: ReadonlySet<string> | readonly string[],
domain: string,
year: number,
): boolean {
if (!domain || !year) return false;
const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys);
const nearby = existenceNearbyYears(domain);
for (const key of asked) {
const match = key.match(SEMANTIC_YEAR);
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 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: { domain: string; year: number },
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<string>();
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[]): boolean {
return methods.some((item) => (
(item.method_id === "dasha_events"
|| item.method_id === "d9_relationship"
|| item.method_id === "d10_career"
|| item.method_id === "relatives")
&& item.status === "uncovered"
));
}
function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean {
return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health");
}
function declinedHealth(declined: ReadonlySet<string>): boolean {
return declined.has("health") || declined.has("health_pressure");
}
function topicDomain(topic: Readonly<Record<string, unknown>>): string | null {
return typeof topic.target_domain === "string"
? topic.target_domain
: typeof topic.targetDomain === "string"
? topic.targetDomain
: null;
}
function topicQuestionId(topic: Readonly<Record<string, unknown>>): string {
return typeof topic.questionId === "string"
? topic.questionId
: typeof topic.question_id === "string"
? topic.question_id
: "";
}
function isOpeningOtherCollectTopic(topic: Readonly<Record<string, unknown>>): boolean {
return topicDomain(topic) === "other" && topicQuestionId(topic).startsWith("collect:other:");
}
function topicIntent(topic: Readonly<Record<string, unknown>>): string {
return typeof topic.intent === "string" ? topic.intent : "";
}
function declinedDomains(
topics: readonly Readonly<Record<string, unknown>>[],
): Set<string> {
const domains = new Set<string>();
for (const topic of topics) {
if (isOpeningOtherCollectTopic(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;
const domain = topicDomain(topic);
if (domain) domains.add(domain);
}
return domains;
}
export function declinedCollectDomains(
topics: readonly Readonly<Record<string, unknown>>[] | undefined,
): Set<string> {
return declinedDomains(topics ?? []);
}
function domainCollectFocusAsked(
topics: readonly Readonly<Record<string, unknown>>[],
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<Record<string, unknown>>[],
): 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<string>([
"dasha_boundary",
"dasha_activation",
]);
function reverseVerifyProbeAsked(
probe: DiscriminatingEventProbe,
askedKeys: ReadonlySet<string>,
): boolean {
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const split = probe.candidate_split_hash ?? "";
return askedKeys.has(semantic)
|| (split !== "" && askedKeys.has(split))
|| askedKeys.has(`${probe.domain}.${probe.year}`)
|| existenceProbeAsked(askedKeys, probe.domain, probe.year);
}
export function remainingReverseVerifyProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
askedKeys: ReadonlySet<string> = 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<Record<string, unknown>>[];
askedProbeKeys?: readonly string[];
birthDate?: string | null;
}): DiscriminatingEventProbe[] {
return remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declinedDomains(input.declinedTopics ?? []),
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<string>,
askedKeys: ReadonlySet<string> = 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))
|| existenceProbeAsked(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<string>,
topCandidateTimes: readonly string[],
mentionedKeys: ReadonlySet<string> = 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 layer = vargaLayerFromSemanticKey(key);
const asked = askedKeys.has(key)
|| Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash))
|| existenceProbeAsked(askedKeys, 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<string>,
topCandidateTimes: readonly string[],
mentionedKeys: ReadonlySet<string> = 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 layer = vargaLayerFromSemanticKey(working.semanticKey);
const asked = askedKeys.has(working.semanticKey)
|| askedKeys.has(working.candidateSplitHash)
|| askedKeys.has(working.probeId)
|| (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<string, string> = {
education: "升学、转学或换学习环境",
relocation: "搬家或长期住到外地",
relationship: "开始一段认真关系、分手或结婚",
career: "入职、换工作或职责加重",
family: "家人结婚、添丁或住院",
finance: "收入明显变化、大笔支出或欠债",
health_pressure: "生病、受伤或压力特别大",
};
const QUALITY_EVENT_FAMILY: Record<string, string> = {
education: "学业或考试发挥失常、压力特别大",
relocation: "搬家或住处特别折腾",
relationship: "感情里压力特别大或相处明显变难",
career: "职责加重或工作压力特别大",
family: "家人结婚、添丁、住院或家里特别操心",
finance: "收入或财务压力特别大",
health_pressure: "生病、受伤或压力特别大",
};
const YEARLESS_COLLECT_LEAD: Record<string, string> = {
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<MethodFollowup, "must_not_label" | "choice_frame">,
): 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 true;
const year = row.eventProbe?.year ?? row.contrastProbe?.year ?? 0;
return year > 0;
}
function rankRenderableDiscriminators(input: {
eventProbes: readonly DiscriminatingEventProbe[];
contrastProbes: readonly CandidateDiscriminatorProbe[];
askedKeys: ReadonlySet<string>;
mentionedKeys?: ReadonlySet<string>;
topCandidateTimes?: readonly string[];
providedDomains?: readonly string[];
evidence?: readonly MethodFollowupEvidence[];
birthDate?: string | null;
}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
const top = input.topCandidateTimes ?? [];
const provided = new Set(input.providedDomains ?? []);
const mentioned = input.mentionedKeys ?? new Set();
const rows: RankedDiscriminator[] = [];
const dropped: DroppedProbe[] = [];
const seen = new Set<string>();
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)),
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && discriminatorChoiceKind(row) !== "varga_style"),
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<MethodFollowup, "intent" | "ask_theme" | "source" | "probe_year" | "year_label">,
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): 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;
const domain = followup.domain ?? "";
const openingOther = followup.source === "method_coverage"
&& domain === "other"
&& followup.collect_retry !== true;
const base = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION)
: openingOther
? GENERIC_COLLECT_QUESTION
: (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION);
return base;
}
export const DATED_COLLECT_ORDER = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
] as const;
function datedCollectFollowup(
domain: keyof typeof REVERSE_VERIFY_THEME,
evidence: readonly MethodFollowupEvidence[],
): MethodFollowup | null {
const lead = YEARLESS_COLLECT_LEAD[domain];
if (!lead) return null;
return {
method_id: PROBE_METHOD_ID[domain],
intent: "collect_method_evidence",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: collectHint(lead, REVERSE_VERIFY_VARGA[domain], "", evidence),
must_not_label: false,
choice_frame: null,
source: "method_coverage",
};
}
function datedCollectDomainBlocked(
domain: (typeof DATED_COLLECT_ORDER)[number],
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
answeredYes: ReadonlySet<string> = 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 nextDatedCollectFollowup(
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
answeredYes: ReadonlySet<string> = new Set(),
): MethodFollowup | null {
for (const domain of DATED_COLLECT_ORDER) {
if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue;
const next = datedCollectFollowup(domain, evidence);
if (next) return next;
}
return null;
}
const REMAINING_EVIDENCE_COLLECT_DOMAINS = new Set<string>([
...DATED_COLLECT_ORDER,
"occupation",
]);
/** Spoken collect that can still add distinguishing evidence. Not `other` / horary. */
export function isRemainingEvidenceCollect(
followup: Pick<MethodFollowup, "intent" | "domain" | "date_reliability_evidence_id"> | null | undefined,
): boolean {
if (!followup || followup.intent !== "collect_method_evidence") return false;
if (followup.date_reliability_evidence_id) return false;
return typeof followup.domain === "string"
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
}
function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "other",
kind_hint: null,
user_prompt_hint: collectHint(
USER_COLLECT_QUESTION.other,
"本命 Dasha + 行运(方法1",
"",
evidence,
),
must_not_label: false,
choice_frame: null,
source: "oos_blind",
};
}
export function exhaustionSpokenCollectFollowup(input: {
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
answeredProbes?: readonly AnsweredProbeCoverageRow[];
eventProbes?: readonly DiscriminatingEventProbe[];
}): MethodFollowup | null {
const declined = declinedDomains(input.declinedTopics ?? []);
const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes);
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
if (dated) return dated;
if (
!declined.has("occupation")
&& !hasConfirmedDomain(input.evidence, "occupation")
&& !occupationCollectFocusClosed(input.declinedTopics ?? [])
) {
return {
method_id: "occupation",
intent: "collect_method_evidence",
ask_theme: "occupation",
domain: "occupation",
kind_hint: "occupation_note",
user_prompt_hint: collectHint(USER_COLLECT_QUESTION.occupation, "D1-H10 + D10", "", input.evidence),
must_not_label: false,
choice_frame: null,
source: "method_coverage",
};
}
return null;
}
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 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",
};
}
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<NextUserActionId, "ask_method_followup" | "ask_candidate_discriminator" | "ask_holdout_validation" | "ask_block_choice" | "ask_window_widen">;
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<typeof decideRectification> {
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) : undefined,
inferenceRounds: input.inferenceRounds,
effectiveAnswerCount: input.effectiveAnswerCount,
plateauRounds: input.plateauRounds,
});
}
export function conversationalSessionOutcome(input: Parameters<typeof decideConversationalSession>[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<MethodFollowup, "must_not_label" | "choice_frame"> | null {
const domain = prompt?.domain || reserved?.domain || "";
if (!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] ?? GENERIC_COLLECT_QUESTION,
source: "method_coverage",
};
}
export function holdoutFollowupFor(
input: {
evidence: readonly MethodFollowupEvidence[];
oosBlindPrompts?: readonly OosBlindPrompt[];
holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[];
},
declined: ReadonlySet<string>,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
const occupied = new Set(
input.evidence
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
.map((item) => item.domain),
);
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
item.domain && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !declined.has(item.domain) && !occupied.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<Record<string, unknown>>[];
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<Record<string, unknown>>[];
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<MethodFollowup, "must_not_label" | "choice_frame">,
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);
return {
...base,
...(collectRetry ? { collect_retry: true } : {}),
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 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"
? { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] }
: 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,
})
: { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] };
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")
|| (input.caseStage === "block_scan" && sessionOutcome === "compare_blocks")
),
);
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: 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,
declined,
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;
}
}
}
if (!next && input.holdoutValidation === "not_started") {
const fields = holdoutFollowupFor(input, declined);
if (fields) next = makeFollowup(fields, false);
}
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",
"不要求一次列出 1015 条。",
),
source: "method_coverage",
});
}
if (!next && dashaCovered && coverageComplete && occupationCovered) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
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
&& input.holdoutValidation !== "not_started"
&& !datedMethodCollectOpen(methods)
&& input.nakshatraProbe
) {
const probe = input.nakshatraProbe;
const nakshatraFollowup = makeFollowup({
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
domain: probe.domain,
kind_hint: null,
user_prompt_hint: probe.question,
source: "nakshatra_boundary",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
probe_year: probe.year,
choice_kind: probe.choice_kind ?? "varga_style",
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
style_options: probe.style_options,
probe_id: probe.id,
}, true, true);
if (nakshatraFollowup.choice_frame) {
next = nakshatraFollowup;
} else {
extraDropped.push({
semantic_key: probe.semantic_key,
information_gain: probe.information_gain,
reason: "not_renderable",
});
}
}
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 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) 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 datedCollect: MethodFollowup | null = null;
let renderableYearless: MethodFollowup | null = null;
if (!next) {
const precisionCard = precisionStageFollowup();
if (!relationshipCovered && !declined.has("relationship")) {
next = sameDomainYearlessCard("relationship") ?? 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 = sameDomainYearlessCard("career") ?? 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 = sameDomainYearlessCard("family") ?? 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 ((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 ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes))) {
next = makeFollowup(datedCollect);
} else if (!occupationCovered) {
if (meetsAcceptanceEventQuality(input.evidence)) {
for (const ranked of yearlessDiscriminators) {
const domain = contrastFollowupDomain(
ranked.eventProbe?.domain ?? ranked.contrastProbe?.domain ?? null,
);
if (declined.has(domain)) continue;
const key = rankedDiscriminatorKey(ranked);
if (key && askedKeys.has(key)) continue;
const candidate = followupFromRanked(ranked);
if (candidate.choice_frame) {
next = candidate;
break;
}
}
}
if (!next) {
next = sameDomainYearlessCard("occupation") ?? makeFollowup({
method_id: "occupation",
intent: "collect_method_evidence",
ask_theme: "occupation",
domain: "occupation",
kind_hint: "occupation_note",
user_prompt_hint: collect(
`${USER_COLLECT_QUESTION.occupation}对照本命第 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 = 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 && next.source === "nakshatra_boundary") {
if (next.semantic_key) {
extraDropped.push({
semantic_key: next.semantic_key,
information_gain: next.information_gain ?? 0,
reason: "not_renderable",
});
}
next = null;
}
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<typeof buildMethodFollowupPlan>[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
// AD 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<string, unknown>
: 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,
});
}