Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/method-followup.ts
T
Jesse_ChenandCursor 68f0759eef
Independent Staging Quality Gate / validate (push) Successful in 13m56s
Independent Staging Quality Gate / publish (push) Successful in 16m46s
fix(rectification): persist the next interview after a choice tap
Closing a discriminator used to leave GET without a card after refresh.
Write the next dated question in the same request, skip childhood career
and move probes, and do not continue a read-only turn when that question
is already persisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:30:09 +08:00

1792 lines
70 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 stays out of domain rotation and is only asked at d4_refine.
* Finance/health score if volunteered; they are not method-layer rotation.
* Method coverage finishes before repeating a precision-stage ask.
* Appearance and marks are skipped_by_policy. Horary does not block offering
* time cards. Occupation does block cards until a note exists.
* Method coverage asks for dated events in natural language.
* Known-event quality probes (exam went badly for a year already
* in the ledger) are not reverse-inference cards. Dasha existence
* probes skip a year already in the ledger, not the whole domain.
* Scoring A/B/C/D reverse-inference needs the engine year/month.
* Yearless varga splits do not borrow a ledger year. Pick the next
* dated discriminator, or collect a dated event in that domain.
* Remaining chart discriminators (D9/D10 style and dated dasha
* probes) stay in the pool; the highest information-gain renderable
* probe is asked next, with no preferred domain.
* If holdout is already reserved but training is still short,
* keep collecting a dated event instead of discriminating.
* Once blocking methods are covered, move into candidate discrimination.
* Coverage complete never means adopt. Horary does not block cards.
* A/B/C/D choice frames attach only when candidates already diverge
* (event probes, precision stage, varga observation, nakshatra, or holdout).
* An already-open distinguish card yields if the live catalog winner is a
* different probe. Yearless D24 event_quality yields to a dated dasha probe;
* do not stamp a ledger year onto a yearless scoring card. An open varga
* discriminator must resolve from the contrast packet when Python event
* probes have no row in that domain. Scoring reverse-inference cards need
* the engine year or month. A yearless D12/family contrast does not attach
* A/B/C/D; skip it and either ask the next dated discriminator or collect a
* dated family event in natural language.
*/
import {
buildChoiceFrame,
isPersistedFocusId,
parseAgentChoiceCopy,
preferConcreteChoicePrompt,
mergeChoiceCard,
type RectificationChoiceCard,
type RectificationChoiceFrame,
} from "./choice-card.ts";
import { overlayChoicePromptFromSpoken } from "./turn-narration.ts";
import { decideRectification, type HoldoutValidationStatus } from "../core/rectification-decision.ts";
import {
askedKeysFromLedgerEvidence,
datedDomainsFromEvidence,
isStructuredDiscriminator,
selectDiscriminatorProbe,
type CandidateContrastPacket,
type CandidateDiscriminatorProbe,
} from "../core/candidate-contrast-packet.ts";
import { candidateIdsFromProbe, isValidDistinguishProbe } from "../core/distinguish-contract.ts";
import {
completeStyleOptions,
isRenderableProbe,
rankDiscriminatorScore,
} from "./probe-question-contract.ts";
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
import { meetsAcceptanceEventQuality, trainingScoreableGate } from "./evidence-model";
import type {
DiscriminatingEventProbe,
EventProbeDomain,
EventProbeStyleOption,
NakshatraBoundary,
OosBlindPrompt,
PrecisionStageId,
} from "./refinement-packet";
import { EVENT_PROBE_DOMAINS } from "./refinement-packet";
import type { InternalVargaObservation } from "./varga-observations";
export const METHOD_FOLLOWUP_IDS = [
"dasha_events",
"d9_relationship",
"d10_career",
"relatives",
"appearance",
"marks",
"occupation",
"horary",
] as const;
export type MethodFollowupId = (typeof METHOD_FOLLOWUP_IDS)[number];
export type MethodCoverageStatus = "covered" | "uncovered" | "skipped_by_policy";
export type MethodCoverage = Readonly<{
method_id: MethodFollowupId;
status: MethodCoverageStatus;
}>;
export type MethodFollowup = Readonly<{
method_id: "dasha_events" | "d9_relationship" | "d10_career" | "d4_home" | "d5_education" | "relatives" | "d2_finance" | "d30_health" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_boundary" | "oos_blind" | "reverse_verify" | "holdout_validation";
intent: string;
ask_theme: "dated_event" | "relationship_style" | "career_style" | "home_change" | "education_style" | "family_event" | "finance_change" | "health_pressure" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_trait" | "oos_blind" | "holdout";
domain: string | null;
kind_hint: string | null;
user_prompt_hint: string;
must_not_label: boolean;
choice_frame: RectificationChoiceFrame | null;
source: "active_focus" | "method_coverage" | "varga_observation" | "precision_stage" | "nakshatra_boundary" | "oos_blind" | "reverse_verify" | "event_probe";
information_gain?: number;
semantic_key?: string;
candidate_split_hash?: string;
probe_year?: number;
year_label?: string;
probe_month?: number;
choice_kind?: "existence" | "varga_style" | "event_quality";
candidate_ids?: readonly string[];
expected_outcomes?: DiscriminatingEventProbe["expected_outcomes"];
style_options?: readonly Readonly<{
label: string;
answer_class: string;
sign?: string;
}>[];
selection_score?: number;
probe_id?: string;
}>;
export type MethodFollowupPlan = Readonly<{
methods: readonly MethodCoverage[];
next_followup: MethodFollowup | null;
deferred_followup: MethodFollowup | null;
session_outcome: SessionOutcomeKind;
stop_domain_rotation: true;
do_not_poll: readonly [];
not_in_rotation: readonly ["relocation"];
}>;
export type MethodFollowupEvidence = Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
id?: string;
summary?: string | null;
}>;
export type MethodFollowupFocus = Readonly<{
id?: string;
questionId?: string;
intent: string;
targetDomain: string | null;
targetKind: string | null;
expectedAnswerSchema?: Readonly<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",
"occupation",
]);
function isConfirmedDated(item: MethodFollowupEvidence): boolean {
return item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& Boolean(item.occurredFrom || item.occurredTo);
}
function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: string): boolean {
return evidence.some((item) => item.status === "confirmed" && item.domain === domain);
}
function evidenceYear(item: MethodFollowupEvidence): number | null {
const raw = item.occurredFrom || item.occurredTo;
if (!raw || raw.length < 4 || !/^\d{4}/.test(raw)) return null;
const year = Number(raw.slice(0, 4));
return year >= 1900 && year <= 2100 ? year : null;
}
/** 高考与当年 9 月入学通常是同一学年;感情/事业/搬家的邻近年也常是同一段经历。 */
const EXISTENCE_NEARBY_YEARS: Readonly<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;
}
function birthYearFromDate(birthDate: string | null | undefined): number | null {
if (!birthDate || birthDate.length < 4 || !/^\d{4}/.test(birthDate)) return null;
const year = Number(birthDate.slice(0, 4));
return year >= 1900 && year <= 2100 ? year : null;
}
function probeBelowAdultFloor(
probe: Pick<DiscriminatingEventProbe, "domain" | "year">,
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");
}
function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean {
return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health");
}
function declinedHealth(declined: Set<string>): boolean {
return declined.has("health") || declined.has("health_pressure");
}
function declinedDomains(
topics: readonly Readonly<Record<string, unknown>>[],
): Set<string> {
const domains = new Set<string>();
for (const topic of topics) {
const domain = typeof topic.target_domain === "string"
? topic.target_domain
: typeof topic.targetDomain === "string"
? topic.targetDomain
: null;
if (domain) domains.add(domain);
}
return domains;
}
const MAX_REVERSE_VERIFY = 2;
const REVERSE_VERIFY_THEME = {
education: "education_style",
relocation: "home_change",
relationship: "relationship_style",
career: "career_style",
family: "family_event",
finance: "finance_change",
health_pressure: "health_pressure",
} as const;
const REVERSE_VERIFY_KIND = {
education: "education_milestone",
relocation: "home_change",
relationship: "relationship_change",
career: "career_change",
family: "family_event",
finance: "finance_change",
health_pressure: "self_health_event",
} as const;
const REVERSE_VERIFY_VARGA = {
education: "D5 / D24",
relocation: "D4",
relationship: "D9",
career: "D10",
family: "D12 / D7 / D3",
finance: "D2 / D11",
health_pressure: "D30",
} as const;
const PROBE_METHOD_ID = {
education: "d5_education",
relocation: "d4_home",
relationship: "d9_relationship",
career: "d10_career",
family: "relatives",
finance: "d2_finance",
health_pressure: "d30_health",
} as const;
function contrastFollowupDomain(
domain: string | null,
): keyof typeof REVERSE_VERIFY_THEME {
if (domain && domain in REVERSE_VERIFY_THEME) {
return domain as keyof typeof REVERSE_VERIFY_THEME;
}
return "career";
}
function eventProbeFromContrast(probe: CandidateDiscriminatorProbe): DiscriminatingEventProbe | null {
const domain = contrastFollowupDomain(probe.domain);
if (!EVENT_PROBE_DOMAINS.includes(domain as EventProbeDomain)) return null;
const choiceKind = probe.choiceKind ?? "existence";
const styleOptions = completeStyleOptions({
choiceKind,
styleOptions: probe.styleOptions?.map((item) => ({
label: item.label,
answer_class: item.answerClass,
...(item.sign ? { sign: item.sign } : {}),
})),
});
if (!styleOptions) return null;
const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [
...row.supportsCandidateIds,
...row.conflictsCandidateIds,
]))];
return {
year: probe.year ?? 0,
year_label: probe.year ? `${probe.year} 年前后` : "当前这几个候选",
domain: domain as EventProbeDomain,
event_family: followupEventFamily(domain, choiceKind),
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: probe.informationGain,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
candidate_ids: candidateIds,
expected_outcomes: probe.expectedOutcomes.map((row) => ({
answer_class: row.outcomeId,
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
})),
choice_kind: choiceKind,
style_options: styleOptions,
};
}
function liveDistinguishProbe(
focus: MethodFollowupFocus,
eventProbes: readonly DiscriminatingEventProbe[] | undefined,
contrastProbes: readonly CandidateDiscriminatorProbe[] | undefined,
): DiscriminatingEventProbe | null {
const schemaKey = persistedFocusProbeKey(focus);
const matchEvent = (probe: DiscriminatingEventProbe) => (
isValidDistinguishProbe({ ...probe, role: "distinguish" })
&& (schemaKey
? probe.semantic_key === schemaKey
: (!focus.targetDomain || probe.domain === focus.targetDomain))
);
const fromEvents = (eventProbes ?? []).find(matchEvent) ?? null;
if (fromEvents) return fromEvents;
const matchContrast = (probe: CandidateDiscriminatorProbe) => (
schemaKey
? probe.semanticKey === schemaKey
: (!focus.targetDomain || probe.domain === focus.targetDomain)
);
const fromContrast = (contrastProbes ?? []).find(matchContrast);
if (!fromContrast) return null;
const converted = eventProbeFromContrast(fromContrast);
return converted && isValidDistinguishProbe(converted) ? converted : null;
}
const CONFLICT_PROBE_SOURCES = new Set<string>([
"dasha_boundary",
"dasha_activation",
]);
function remainingReverseVerifyProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
birthDate?: string | null,
): DiscriminatingEventProbe[] {
const dasha: DiscriminatingEventProbe[] = [];
const fallback: DiscriminatingEventProbe[] = [];
for (const probe of probes ?? []) {
if (probe.source === "known_event_quality" || probe.role === "clarify" || probe.phase === "event_clarification") continue;
if (probe.role === "collect" || probe.phase === "evidence_collection") continue;
if (declined.has(probe.domain)) continue;
if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
if (probeBelowAdultFloor(probe, birthDate)) continue;
if (CONFLICT_PROBE_SOURCES.has(probe.source)) {
dasha.push(probe);
} else {
fallback.push(probe);
}
}
return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY);
}
function datedCollectionProbe(
probes: readonly DiscriminatingEventProbe[] | undefined,
domain: string,
): DiscriminatingEventProbe | null {
const rows = (probes ?? []).filter((item) => item.domain === domain && Number(item.year) > 0);
return rows.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))[0] ?? null;
}
function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick<
MethodFollowup,
"probe_year" | "year_label" | "probe_month" | "semantic_key"
> {
if (!probe) return {};
return {
probe_year: probe.year,
year_label: probe.year_label,
...(probe.month ? { probe_month: probe.month } : {}),
semantic_key: probe.semantic_key,
};
}
function remainingConflictProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<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))) continue;
rows.push(probe);
}
return rows
.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))
.slice(0, MAX_REVERSE_VERIFY);
}
type RankedDiscriminator = Readonly<{
kind: "event" | "contrast";
score: number;
eventProbe?: DiscriminatingEventProbe;
contrastProbe?: CandidateDiscriminatorProbe;
styleOptions: NonNullable<ReturnType<typeof completeStyleOptions>>;
}>;
function renderableEventProbe(
probe: DiscriminatingEventProbe,
askedKeys: ReadonlySet<string>,
topCandidateTimes: readonly string[],
): RankedDiscriminator | null {
const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe);
const styleOptions = completeStyleOptions({
choiceKind: probe.choice_kind,
styleOptions: probe.style_options,
});
if (!styleOptions || !isValidDistinguishProbe({ ...probe, role: "distinguish" })) return null;
if (!isRenderableProbe({
informationGain: probe.information_gain,
candidateIds,
expectedOutcomeCount: probe.expected_outcomes?.length,
choiceKind: probe.choice_kind,
styleOptions,
})) return null;
const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash));
return {
kind: "event",
eventProbe: probe,
styleOptions,
score: rankDiscriminatorScore({
informationGain: probe.information_gain ?? 0,
asked,
candidateIds,
topCandidateTimes,
}),
};
}
function renderableContrastProbe(
probe: CandidateDiscriminatorProbe,
askedKeys: ReadonlySet<string>,
topCandidateTimes: readonly string[],
): RankedDiscriminator | null {
const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [
...row.supportsCandidateIds,
...row.conflictsCandidateIds,
]))];
const styleOptions = completeStyleOptions({
choiceKind: probe.choiceKind,
styleOptions: probe.styleOptions?.map((item) => ({
label: item.label,
answer_class: item.answerClass,
...(item.sign ? { sign: item.sign } : {}),
})),
});
if (!styleOptions || !isRenderableProbe({
informationGain: probe.informationGain,
candidateIds,
expectedOutcomeCount: probe.expectedOutcomes.length,
choiceKind: probe.choiceKind,
styleOptions,
})) return null;
const asked = askedKeys.has(probe.semanticKey)
|| askedKeys.has(probe.candidateSplitHash)
|| askedKeys.has(probe.probeId);
return {
kind: "contrast",
contrastProbe: probe,
styleOptions,
score: rankDiscriminatorScore({
informationGain: probe.informationGain,
asked,
candidateIds,
topCandidateTimes,
}),
};
}
const EXISTENCE_EVENT_FAMILY: Record<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";
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>;
topCandidateTimes?: readonly string[];
providedDomains?: readonly string[];
evidence?: readonly MethodFollowupEvidence[];
}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[] } {
const top = input.topCandidateTimes ?? [];
const provided = new Set(input.providedDomains ?? []);
const rows: RankedDiscriminator[] = [];
const seen = new Set<string>();
const push = (row: RankedDiscriminator | null) => {
if (!row) return;
const key = row.eventProbe?.semantic_key
?? row.contrastProbe?.semanticKey
?? "";
if (!key || seen.has(key)) return;
seen.add(key);
rows.push(row);
};
for (const probe of input.eventProbes) {
push(renderableEventProbe(probe, input.askedKeys, top));
}
for (const probe of input.contrastProbes) {
if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) continue;
push(renderableContrastProbe(probe, input.askedKeys, top));
}
const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0));
return {
locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)),
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && discriminatorChoiceKind(row) !== "varga_style"),
};
}
function coverage(
methodId: MethodFollowupId,
status: MethodCoverageStatus,
): MethodCoverage {
return { method_id: methodId, status };
}
function collectHint(
why: string,
varga: string,
extra = "",
evidence: readonly MethodFollowupEvidence[] = [],
): string {
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}用自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
}
function agentHint(
why: string,
varga: string,
extra = "",
evidence: readonly MethodFollowupEvidence[] = [],
): string {
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用简体中文自己写一句追问;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim();
}
export function shouldAttachChoiceFrame(
item: Pick<MethodFollowup, "intent" | "ask_theme" | "source">,
evidence: readonly MethodFollowupEvidence[] = [],
): boolean {
if (item.intent === "out_of_sample_check" || item.source === "oos_blind") return true;
if (item.intent === "reverse_verify" || item.source === "reverse_verify") return true;
if (item.intent === "clarify_event") return true;
if (item.source === "event_probe") return true;
if (item.ask_theme === "nakshatra_trait" || item.source === "nakshatra_boundary") return true;
if (!evidence.some(isConfirmedDated)) return false;
if (item.intent === "distinguish_candidates") return true;
if (item.source === "varga_observation" || item.source === "precision_stage") return true;
return false;
}
const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
relationship: "还记得别的带年份的感情变化吗?比如开始认真交往、分手或结婚。",
career: "还记得别的带年份的工作变化吗?比如入职、换工作或职责加重。",
family: "家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。",
occupation: "你长期做什么工作?",
education: "有没有记得住年份的升学、转学或考试?",
relocation: "有没有记得住时间的搬家或长期住到外地?",
};
export function spokenFollowupForUser(followup: MethodFollowup | null): string | null {
if (!followup) return null;
if (followup.choice_frame) return "接下来请点选下面这一问。";
if (followup.intent !== "collect_method_evidence") return null;
const base = USER_COLLECT_QUESTION[followup.domain ?? ""]
?? "请再说一件记得大概时间的经历。";
const period = followup.year_label
?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null);
return period ? `${period}${base}` : base;
}
export type NextUserActionId =
| "adopt_representative"
| "score_now"
| "record_stated_events"
| "ask_method_followup"
| "ask_candidate_discriminator"
| "ask_holdout_validation"
| "offer_provisional_range"
| "explain_current_window"
| "verify_adopted_time"
| "start_consultation";
export type NextUserAction = Readonly<{
id: NextUserActionId;
user_meaning: string;
on_user_stop: {
id: Exclude<NextUserActionId, "ask_method_followup" | "ask_candidate_discriminator" | "ask_holdout_validation">;
user_meaning: string;
};
}>;
function action(
id: NextUserAction["on_user_stop"]["id"],
user_meaning: string,
): NextUserAction["on_user_stop"] {
return { id, user_meaning };
}
export function isOfferBlockingFollowup(
followup: MethodFollowup | null,
methods?: readonly MethodCoverage[],
options?: { separated?: boolean },
): boolean {
if (methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered")) {
return true;
}
if (!followup) return false;
const separated = options?.separated === true;
if (followup.source === "event_probe") {
if (separated) return (followup.information_gain ?? 0) >= 0.08;
return true;
}
if (
followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.intent === "distinguish_candidates"
) {
return !separated;
}
if (followup.source !== "method_coverage") return false;
return BLOCKING_COVERAGE_IDS.has(followup.method_id as MethodFollowupId);
}
function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null {
if (!followup) return null;
if (followup.choice_kind === "event_quality" || followup.intent === "clarify_event") return null;
const realProbe = followup.source === "event_probe"
|| followup.source === "reverse_verify"
|| (followup.source === "active_focus" && followup.intent === "distinguish_candidates");
if (!realProbe) return null;
const outcomes = (followup.expected_outcomes ?? []).filter((row) => row.supports.length + row.conflicts.length > 0);
const candidateIds = followup.candidate_ids ?? candidateIdsFromProbe({
role: "distinguish",
expected_outcomes: followup.expected_outcomes,
candidate_ids: followup.candidate_ids,
});
if ((followup.information_gain ?? 0) <= 0 || outcomes.length < 2 || candidateIds.length < 2) {
return null;
}
const split = followup.candidate_split_hash ?? followup.semantic_key ?? followup.method_id;
return {
probeId: split,
candidateSetVersion: split,
question: followup.user_prompt_hint,
expectedOutcomes: outcomes.map((row) => ({
outcomeId: row.answer_class,
supportsCandidateIds: row.supports,
conflictsCandidateIds: row.conflicts,
})),
candidateSplitHash: split,
informationGain: followup.information_gain ?? 0,
sourceFeatures: [{ technique: followup.source, calculationResultId: null }],
domain: followup.domain,
year: followup.probe_year ?? null,
semanticKey: followup.semantic_key ?? followup.method_id,
choiceKind: followup.choice_kind,
styleOptions: followup.style_options?.map((item) => ({
label: item.label,
answerClass: item.answer_class as "yes" | "weak_yes" | "no" | "unsure",
...(item.sign ? { sign: item.sign } : {}),
})),
};
}
export function decideConversationalSession(input: {
selectionAllowed: boolean;
proposeAllowed: boolean;
confirmationAllowed: boolean;
nextFollowup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
userStopped?: boolean;
candidateScores?: readonly Readonly<{ time: string; score: number }>[];
trainingGateOpen?: boolean;
evidence?: readonly MethodFollowupEvidence[];
discriminatorProbe?: CandidateDiscriminatorProbe | null;
holdoutValidation?: HoldoutValidationStatus;
snapshotCurrent?: boolean;
accepted?: boolean;
}): ReturnType<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,
});
}
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",
"当前几个候选基本并列。说明这是可信区间,代表分钟只是计算用的代表点,不要称某分钟为当前推荐;不要继续假装已经收敛。",
);
if (input.sessionOutcome === "awaiting_confirmation") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (input.sessionOutcome === "adopt_representative") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (input.sessionOutcome === "provisional_range"
|| input.sessionOutcome === "completed_with_range"
|| input.sessionOutcome === "provisional_range_user_stopped") {
return { id: provisional.id, user_meaning: provisional.user_meaning, on_user_stop: provisional };
}
if (input.sessionOutcome === "validated_range" || input.sessionOutcome === "exact_minute_confirmed") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (input.sessionOutcome === "validate_holdout" && input.nextFollowup) {
return {
id: "ask_holdout_validation",
user_meaning: input.nextFollowup.user_prompt_hint,
on_user_stop: input.selectionAllowed ? provisional : action(
"explain_current_window",
`${working}。独立核对还没做完。用户说没有更多时,说明这是并列区间,不要采用一张赢家卡。`,
),
};
}
if (input.sessionOutcome === "discriminate_candidates" && input.nextFollowup) {
return {
id: "ask_candidate_discriminator",
user_meaning: input.nextFollowup.user_prompt_hint,
on_user_stop: input.selectionAllowed ? provisional : action(
"explain_current_window",
`${working}。候选还没拉开。用户说没有更多时,给出并列可信区间,不要宣布某分钟胜出。`,
),
};
}
const scoreNow = action(
"score_now",
"已有可评分事件但还没有候选结果。本轮必须比较候选,不要只口头确认事件。",
);
if (input.scorableCount > 0 && !input.hasLatestResult) {
return { id: scoreNow.id, user_meaning: scoreNow.user_meaning, on_user_stop: scoreNow };
}
const record = action(
"record_stated_events",
"账本里还没有带日期事件。若用户已经说过带日期的经历,本轮必须用 batch 写入后再比较;不要只在口头上复述。",
);
if (input.evidenceCount === 0 && input.scorableCount === 0) {
return { id: record.id, user_meaning: record.user_meaning, on_user_stop: record };
}
const explain = action(
"explain_current_window",
`${working}。现有事件还不够给出可采用的代表性时间。用户说没有更多时,说明缺什么、可以以后再补,或先用当前填报时间去咨询看盘;不要只说会话会保留。`,
);
if (input.nextFollowup) {
return {
id: "ask_method_followup",
user_meaning: input.nextFollowup.user_prompt_hint,
on_user_stop: input.hasLatestResult ? provisional : explain,
};
}
return { id: explain.id, user_meaning: explain.user_meaning, on_user_stop: explain };
}
export function buildMethodFollowupPlan(input: {
evidence: readonly MethodFollowupEvidence[];
activeFocus?: MethodFollowupFocus | null;
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
observations?: readonly InternalVargaObservation[];
sessionOutcome?: SessionOutcomeKind;
precisionStage?: PrecisionStageId | null;
nakshatraBoundary?: NakshatraBoundary | null;
oosBlindPrompts?: readonly OosBlindPrompt[];
eventProbes?: readonly DiscriminatingEventProbe[];
eventClarificationProbes?: readonly DiscriminatingEventProbe[];
evidenceCollectionProbes?: readonly DiscriminatingEventProbe[];
askedProbeKeys?: readonly string[];
birthDate?: string | null;
accepted?: boolean;
candidatesSeparated?: boolean;
contrastPacket?: CandidateContrastPacket | null;
topCandidateTimes?: readonly string[];
holdoutValidation?: HoldoutValidationStatus;
holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[];
}): MethodFollowupPlan {
const makeFollowup = (
item: Omit<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);
return {
...base,
choice_frame: attach
? buildChoiceFrame(base, {
observations: input.observations,
evidence: input.evidence,
probes: [
...(ownedProbe ? [ownedProbe] : []),
...(input.eventProbes ?? []),
...(input.eventClarificationProbes ?? []),
...(input.evidenceCollectionProbes ?? []),
],
birthDate: input.birthDate,
scoring,
})
: null,
};
};
const collect = (why: string, varga: string, extra = "") =>
collectHint(why, varga, extra, input.evidence);
const ask = (why: string, varga: string, extra = "") =>
agentHint(why, varga, extra, input.evidence);
const declined = declinedDomains(input.declinedTopics ?? []);
const dashaCovered = input.evidence.some(isConfirmedDated);
const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship");
const careerCovered = hasConfirmedDomain(input.evidence, "career");
const familyCovered = hasConfirmedDomain(input.evidence, "family");
const financeCovered = hasConfirmedDomain(input.evidence, "finance");
const healthCovered = hasConfirmedHealth(input.evidence);
const occupationCovered = hasConfirmedDomain(input.evidence, "occupation")
|| input.evidence.some(isOccupationNote)
|| declined.has("occupation");
const horaryGiven = hasConfirmedDomain(input.evidence, "horary");
const horaryStatus: MethodCoverageStatus = horaryGiven
? "covered"
: declined.has("horary")
? "skipped_by_policy"
: "uncovered";
const methods: MethodCoverage[] = [
coverage("dasha_events", dashaCovered ? "covered" : "uncovered"),
coverage("d9_relationship", relationshipCovered || declined.has("relationship") ? "covered" : "uncovered"),
coverage("d10_career", careerCovered || declined.has("career") ? "covered" : "uncovered"),
coverage("relatives", familyCovered || declined.has("family") ? "covered" : "uncovered"),
coverage("appearance", "skipped_by_policy"),
coverage("marks", "skipped_by_policy"),
coverage("occupation", occupationCovered ? "covered" : "uncovered"),
coverage("horary", horaryStatus),
];
const sessionOutcome = input.sessionOutcome ?? "collect_evidence";
const candidatesSeparated = input.candidatesSeparated === true;
const contrastProbes = candidatesSeparated ? [] : [...(input.contrastPacket?.probes ?? [])];
// Legacy known-event quality cards were never backed by an inference probe.
// Ignore them so existing cases resume evidence collection instead of exposing a stale card.
const focus = input.activeFocus?.intent === "clarify_event" ? null : input.activeFocus ?? null;
const keepAcceptedFocus = Boolean(
focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"),
);
const coverageComplete = blockingMethodsCovered(methods);
const askedKeys = new Set([
...(input.askedProbeKeys ?? []),
...askedKeysFromLedgerEvidence(input.evidence),
]);
const rankedCatalog = dashaCovered && meetsAcceptanceEventQuality(input.evidence)
? rankRenderableDiscriminators({
eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys, input.birthDate),
contrastProbes,
askedKeys,
topCandidateTimes: input.topCandidateTimes,
providedDomains: datedDomainsFromEvidence(input.evidence),
evidence: input.evidence,
})
: { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[] };
const rankedDiscriminators = rankedCatalog.locked;
const yearlessDiscriminators = rankedCatalog.yearless;
const bestDiscriminator = rankedDiscriminators[0] ?? null;
const catalogWinnerKey = rankedDiscriminatorKey(bestDiscriminator);
const staleCollectFocus = Boolean(
focus
&& focus.intent === "collect_method_evidence"
&& (
(focus.targetDomain === "occupation" && occupationCovered)
|| (focus.targetDomain === "relationship" && (relationshipCovered || declined.has("relationship")))
|| (focus.targetDomain === "career" && (careerCovered || declined.has("career")))
|| (focus.targetDomain === "family" && (familyCovered || declined.has("family")))
|| (focus.targetDomain === "horary" && horaryStatus !== "uncovered")
),
);
const staleDiscriminatorFocus = Boolean(
focus
&& focus.intent === "distinguish_candidates"
&& catalogWinnerKey
&& persistedFocusProbeKey(focus)
&& persistedFocusProbeKey(focus) !== catalogWinnerKey
);
if (
focus
&& !staleCollectFocus
&& !staleDiscriminatorFocus
&& (sessionOutcome !== "adopt_representative"
&& sessionOutcome !== "validated_range"
&& sessionOutcome !== "exact_minute_confirmed"
&& sessionOutcome !== "provisional_range"
&& sessionOutcome !== "provisional_range_user_stopped"
&& sessionOutcome !== "completed_with_range"
|| keepAcceptedFocus)
&& (!input.accepted || keepAcceptedFocus)
) {
const existingChoice = parseAgentChoiceCopy(focus.expectedAnswerSchema ?? null);
const reverseVerify = focus.intent === "reverse_verify";
const keepChoice = reverseVerify || (Boolean(existingChoice) && (
input.evidence.some(isConfirmedDated)
|| focus.intent === "out_of_sample_check"
));
const liveProbe = liveDistinguishProbe(
focus,
input.eventProbes,
input.contrastPacket?.probes,
);
const keepNext = makeFollowup({
method_id: "active_focus",
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。自己写一句追问;年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。不要调用 set-focus。正文不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则继续用自然语言问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
information_gain: liveProbe.information_gain,
semantic_key: liveProbe.semantic_key,
candidate_split_hash: liveProbe.candidate_split_hash,
probe_year: liveProbe.year,
year_label: liveProbe.year_label,
probe_month: liveProbe.month,
choice_kind: liveProbe.choice_kind,
candidate_ids: liveProbe.candidate_ids ?? candidateIdsFromProbe(liveProbe),
expected_outcomes: liveProbe.expected_outcomes,
style_options: liveProbe.style_options,
}
: {}),
}, true, keepChoice);
if (!(
keepChoice
&& focus.intent === "distinguish_candidates"
&& !keepNext.choice_frame
)) {
return {
methods,
next_followup: keepNext,
deferred_followup: null,
session_outcome: sessionOutcome,
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
};
}
}
if (sessionOutcome === "validate_holdout") {
const prompt = input.oosBlindPrompts?.[0] ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null;
const holdoutNext = prompt
? makeFollowup({
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
}, false, true)
: reserved
? makeFollowup({
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
}, false, true)
: null;
return {
methods,
next_followup: holdoutNext,
deferred_followup: null,
session_outcome: sessionOutcome,
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
};
}
if (input.accepted) {
const probe = remainingReverseVerifyProbes(input.eventProbes, input.evidence, declined, input.birthDate)[0] ?? null;
const theme = probe ? REVERSE_VERIFY_THEME[probe.domain] : null;
const next = probe && theme
? makeFollowup({
method_id: "reverse_verify",
intent: "reverse_verify",
ask_theme: theme,
domain: probe.domain,
kind_hint: REVERSE_VERIFY_KIND[probe.domain],
user_prompt_hint: ask(
`当前排盘已采用。按该分钟核对:${probe.year_label} 是否有${probe.event_family}。对得上写入账本并重算;对不上可以改选其他候选。不确认唯一分钟。`,
REVERSE_VERIFY_VARGA[probe.domain],
),
source: "reverse_verify",
}, true, true)
: null;
return {
methods,
next_followup: next,
deferred_followup: null,
session_outcome: sessionOutcome,
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
};
}
let next: MethodFollowup | null = null;
const stage = input.precisionStage ?? null;
const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => {
if (ranked.kind === "event" && ranked.eventProbe) {
const conflictProbe = ranked.eventProbe;
return makeFollowup({
method_id: PROBE_METHOD_ID[conflictProbe.domain],
intent: "distinguish_candidates",
ask_theme: REVERSE_VERIFY_THEME[conflictProbe.domain],
domain: conflictProbe.domain,
kind_hint: REVERSE_VERIFY_KIND[conflictProbe.domain],
user_prompt_hint: ask(
`当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`,
REVERSE_VERIFY_VARGA[conflictProbe.domain],
),
source: "event_probe",
information_gain: conflictProbe.information_gain ?? 0,
semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`,
candidate_split_hash: conflictProbe.candidate_split_hash,
probe_year: conflictProbe.year,
year_label: conflictProbe.year_label,
probe_month: conflictProbe.month,
choice_kind: conflictProbe.choice_kind ?? "existence",
candidate_ids: conflictProbe.candidate_ids ?? candidateIdsFromProbe(conflictProbe),
expected_outcomes: conflictProbe.expected_outcomes,
style_options: ranked.styleOptions,
selection_score: ranked.score,
probe_id: conflictProbe.semantic_key,
}, true, true);
}
const contrast = ranked.contrastProbe!;
const domain = contrastFollowupDomain(contrast.domain);
const expectedOutcomes = contrast.expectedOutcomes.map((row) => ({
answer_class: row.outcomeId,
supports: row.supportsCandidateIds,
conflicts: row.conflictsCandidateIds,
}));
return makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "distinguish_candidates",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: ask(
contrast.question,
REVERSE_VERIFY_VARGA[domain],
"按候选盘面差异核对前事,不要问两套盘哪个更像。",
),
source: "event_probe",
information_gain: contrast.informationGain,
semantic_key: contrast.semanticKey,
candidate_split_hash: contrast.candidateSplitHash,
probe_year: contrast.year ?? undefined,
choice_kind: contrast.choiceKind ?? "existence",
candidate_ids: [...new Set(contrast.expectedOutcomes.flatMap((row) => [
...row.supportsCandidateIds,
...row.conflictsCandidateIds,
]))],
expected_outcomes: expectedOutcomes,
style_options: ranked.styleOptions,
selection_score: ranked.score,
probe_id: contrast.probeId,
}, true, true);
};
if (!dashaCovered) {
next = makeFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: collect(
"可以先从最容易想起的一件带大概时间的经历开始。",
"本命 Dasha + 行运(方法1",
"不要求一次列出 1015 条。",
),
source: "method_coverage",
});
} else {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
for (const ranked of rankedDiscriminators) {
if (!allowLowGainDiscriminator && ranked.score < 0.08) continue;
const candidate = followupFromRanked(ranked);
if (candidate.choice_frame) {
next = candidate;
break;
}
}
if (!next && coverageComplete) {
const yearless = yearlessDiscriminators[0];
if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) {
const domain = contrastFollowupDomain(
yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null,
);
const lead = YEARLESS_COLLECT_LEAD[domain];
if (lead && !declined.has(domain)) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "collect_method_evidence",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]),
source: "method_coverage",
});
}
}
}
}
if (!next) {
if (!relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
method_id: "d9_relationship",
intent: "collect_method_evidence",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_start",
user_prompt_hint: collect(
"可以先说一段记得大概时间的感情变化,比如开始认真交往、分手或结婚。",
"D9",
"对照 D9 上升类型表(白羊主动热情、天蝎深刻占有等)只作校时方法,不是命运承诺。",
),
source: "method_coverage",
});
} else if (!careerCovered && !declined.has("career")) {
next = makeFollowup({
method_id: "d10_career",
intent: "collect_method_evidence",
ask_theme: "career_style",
domain: "career",
kind_hint: "career_entry",
user_prompt_hint: collect(
"可以先说一段记得大概时间的工作变化,比如入职、换工作或职责加重。同一件事会同时对照本命第 10 宫和 D10。",
"D10",
"可用 D10 事业类型表作校时对照。",
),
source: "method_coverage",
});
} else if (!familyCovered && !declined.has("family")) {
const familyCollect = datedCollectionProbe(input.evidenceCollectionProbes, "family");
next = makeFollowup({
method_id: "relatives",
intent: "collect_method_evidence",
ask_theme: "family_event",
domain: "family",
kind_hint: "family_event",
user_prompt_hint: collect(
"可以先问家里有没有结婚、添丁或住院这类记得住时间的事。同一件事会对照 D12 父母盘、D7 子女盘和 D3 兄弟盘。",
"D12 / D7 / D3",
"六亲用 Raman 六步:前三步可执行,后三步标方法论层。",
),
source: "method_coverage",
...collectionYearFields(familyCollect),
});
} else if (!occupationCovered) {
next = makeFollowup({
method_id: "occupation",
intent: "collect_method_evidence",
ask_theme: "occupation",
domain: "occupation",
kind_hint: "occupation_note",
user_prompt_hint: collect(
"你长期做什么工作?对照本命第 10 宫和 D10。",
"D1-H10 + D10",
"可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。",
),
source: "method_coverage",
});
} else if (
!meetsAcceptanceEventQuality(input.evidence)
&& !(stage === "d5_refine" && !hasConfirmedDomain(input.evidence, "education") && !declined.has("education"))
&& !(stage === "d9_refine" && !relationshipCovered && !declined.has("relationship"))
&& !(stage === "d10_refine" && !careerCovered && !declined.has("career"))
&& !((stage === "d4_refine" || stage === "theme_refine")
&& !hasConfirmedDomain(input.evidence, "relocation")
&& !declined.has("relocation"))
) {
next = makeFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: collect(
"再记一件记得大概时间的经历。当前用来区分候选的训练事件还不够。",
"本命 Dasha + 行运(方法1",
"不要开始点选区分题。",
),
source: "method_coverage",
});
} else if (stage === "lagna_frame") {
next = makeFollowup({
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: ask(
"窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。",
"本命上升 / Dasha",
),
source: "precision_stage",
});
} else if (stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: ask(
"关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。",
"D9",
"对照 D9 上升类型表只作校时方法。",
),
source: "precision_stage",
});
} else if (stage === "d10_refine" && !declined.has("career")) {
next = makeFollowup({
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: "career_change",
user_prompt_hint: ask(
"事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。",
"D10",
"可用 D10 事业类型表作校时对照。",
),
source: "precision_stage",
});
} else if ((stage === "d4_refine" || stage === "theme_refine") && !declined.has("relocation")) {
next = makeFollowup({
method_id: "d4_home",
intent: "distinguish_candidates",
ask_theme: "home_change",
domain: "relocation",
kind_hint: "home_change",
user_prompt_hint: ask(
"居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。",
"D4",
),
source: "precision_stage",
});
} else if (stage === "d5_refine" && !declined.has("education")) {
next = makeFollowup({
method_id: "d5_education",
intent: "distinguish_candidates",
ask_theme: "education_style",
domain: "education",
kind_hint: "education_milestone",
user_prompt_hint: ask(
"成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。",
"D5 / D24",
),
source: "precision_stage",
});
} else {
const d9 = input.observations?.find((item) => item.layer === "d9");
const d10 = input.observations?.find((item) => item.layer === "d10");
const d4 = input.observations?.find((item) => item.layer === "d4");
const d5 = input.observations?.find((item) => item.layer === "d5");
const d7 = input.observations?.find((item) => item.layer === "d7");
const d12 = input.observations?.find((item) => item.layer === "d12");
const d11 = input.observations?.find((item) => item.layer === "d11");
const d30 = input.observations?.find((item) => item.layer === "d30");
if (d9?.candidates_differ && !relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: ask(
"当前候选在关系主题上仍分不开。按探针年份问感情前事是否发生,用来筛窗。不要问两套盘哪个更像。",
"D9",
"对照 D9 上升类型表只作校时方法。",
),
source: "varga_observation",
});
} else if (d10?.candidates_differ && !declined.has("career")) {
next = makeFollowup({
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: "career_change",
user_prompt_hint: ask(
"当前候选在事业主题上仍分不开。按探针年份问事业前事是否发生,用来筛窗。不要问两套盘哪个更像。",
"D10",
"可用事业类型表作校时对照。",
),
source: "varga_observation",
});
} else if (d4?.candidates_differ && !declined.has("relocation")) {
next = makeFollowup({
method_id: "d4_home",
intent: "distinguish_candidates",
ask_theme: "home_change",
domain: "relocation",
kind_hint: "home_change",
user_prompt_hint: ask(
"当前候选在居所主题上仍分不开。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。",
"D4",
),
source: "varga_observation",
});
} else if (d5?.candidates_differ && !declined.has("education")) {
next = makeFollowup({
method_id: "d5_education",
intent: "distinguish_candidates",
ask_theme: "education_style",
domain: "education",
kind_hint: "education_milestone",
user_prompt_hint: ask(
"当前候选在学业或成就主题上仍分不开。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。",
"D5 / D24",
),
source: "varga_observation",
});
} else if ((d12?.candidates_differ || d7?.candidates_differ) && !declined.has("family")) {
next = makeFollowup({
method_id: "relatives",
intent: "distinguish_candidates",
ask_theme: "family_event",
domain: "family",
kind_hint: "family_event",
user_prompt_hint: ask(
"当前候选在家人主题上仍分不开。按探针年份问家人变化是否发生,用来筛窗。不要问两套盘哪个更像。",
"D12 / D7 / D3",
),
source: "varga_observation",
});
} else if (d11?.candidates_differ && financeCovered && !declined.has("finance")) {
next = makeFollowup({
method_id: "d2_finance",
intent: "distinguish_candidates",
ask_theme: "finance_change",
domain: "finance",
kind_hint: "finance_change",
user_prompt_hint: ask(
"当前候选在财务主题上仍分不开。按探针年份问财务变化是否发生,用来筛窗。不要问两套盘哪个更像。",
"D2 / D11",
),
source: "varga_observation",
});
} else if (d30?.candidates_differ && healthCovered && !declinedHealth(declined)) {
next = makeFollowup({
method_id: "d30_health",
intent: "distinguish_candidates",
ask_theme: "health_pressure",
domain: "health_pressure",
kind_hint: "self_health_event",
user_prompt_hint: ask(
"当前候选在健康压力主题上仍分不开。按探针年份问健康或压力变化是否发生,用来筛窗。这不是医学判断。不要问两套盘哪个更像。",
"D30",
),
source: "varga_observation",
});
} else if (input.nakshatraBoundary?.near_boundary) {
next = makeFollowup({
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
domain: null,
kind_hint: null,
user_prompt_hint: input.nakshatraBoundary.user_meaning
?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。",
source: "nakshatra_boundary",
});
} else if (input.holdoutValidation === "not_started") {
const prompt = input.oosBlindPrompts?.[0];
const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null);
if (prompt) {
next = makeFollowup({
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
}, false, true);
} else if (reserved) {
next = makeFollowup({
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
}, false, true);
}
} else if (horaryStatus === "uncovered") {
next = makeFollowup({
method_id: "horary",
intent: "collect_method_evidence",
ask_theme: "horary",
domain: "horary",
kind_hint: "horary_query",
user_prompt_hint: collect(
"有没有第一次认真问起这件事的时间?",
"占问观察盘",
"有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。",
),
source: "method_coverage",
});
}
}
}
const deferAdoption = sessionOutcome === "adopt_representative"
|| sessionOutcome === "validated_range"
|| sessionOutcome === "exact_minute_confirmed"
|| sessionOutcome === "provisional_range"
|| sessionOutcome === "provisional_range_user_stopped"
|| sessionOutcome === "completed_with_range";
return {
methods,
next_followup: deferAdoption ? null : next,
deferred_followup: deferAdoption ? next : null,
session_outcome: sessionOutcome,
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
};
}
export function projectRectificationChoiceCard(
input: Parameters<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 {
let plan = buildMethodFollowupPlan(input);
const sessionOutcome = conversationalSessionOutcome({
selectionAllowed: input.selectionAllowed === true,
proposeAllowed: input.proposeAllowed === true,
confirmationAllowed: input.confirmationAllowed === true,
nextFollowup: plan.next_followup,
methods: plan.methods,
userStopped: input.userStopped,
candidateScores: input.candidateScores,
discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null) ?? undefined,
holdoutValidation: input.holdoutValidation,
evidence: input.evidence,
});
if (sessionOutcome !== (input.sessionOutcome ?? "collect_evidence")) {
plan = buildMethodFollowupPlan({ ...input, sessionOutcome });
}
if (
!input.accepted
&& (sessionOutcome === "adopt_representative"
|| sessionOutcome === "awaiting_confirmation"
|| sessionOutcome === "validated_range"
|| sessionOutcome === "exact_minute_confirmed"
|| sessionOutcome === "provisional_range"
|| sessionOutcome === "provisional_range_user_stopped"
|| sessionOutcome === "completed_with_range")
) {
return null;
}
const focusId = input.activeFocus && "id" in input.activeFocus && typeof input.activeFocus.id === "string"
? input.activeFocus.id.trim()
: "";
if (!isPersistedFocusId(focusId)) return null;
const followup = plan.next_followup ?? plan.deferred_followup ?? null;
if (!followup) return null;
const frame = followup.choice_frame;
if (!frame) return null;
const schema = input.activeFocus?.expectedAnswerSchema ?? null;
const schemaRow = schema && typeof schema === "object" && !Array.isArray(schema)
? schema as Record<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 && schemaRow?.candidate_split_hash !== followup.candidate_split_hash) return null;
if (
!followup.semantic_key
&& !followup.candidate_split_hash
&& input.activeFocus?.questionId
&& input.activeFocus.questionId !== frame.question_id
) return null;
const probeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string"
? (schema as { probe_id: string }).probe_id
: null;
const copy = parseAgentChoiceCopy(schema);
const overlaid = copy
? {
...copy,
prompt: overlayChoicePromptFromSpoken(
preferConcreteChoicePrompt(frame.prompt, copy.prompt),
input.latestAssistantText,
),
}
: null;
return mergeChoiceCard(frame, overlaid, {
question_id: input.activeFocus?.questionId ?? frame.question_id,
probe_id: probeId,
case_revision: input.caseRevision ?? null,
focus_id: focusId,
});
}