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>
1792 lines
70 KiB
TypeScript
1792 lines
70 KiB
TypeScript
/**
|
||
* 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)",
|
||
"不要求一次列出 10–15 条。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else {
|
||
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
|
||
for (const ranked of rankedDiscriminators) {
|
||
if (!allowLowGainDiscriminator && ranked.score < 0.08) continue;
|
||
const candidate = followupFromRanked(ranked);
|
||
if (candidate.choice_frame) {
|
||
next = candidate;
|
||
break;
|
||
}
|
||
}
|
||
if (!next && coverageComplete) {
|
||
const yearless = yearlessDiscriminators[0];
|
||
if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) {
|
||
const domain = contrastFollowupDomain(
|
||
yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null,
|
||
);
|
||
const lead = YEARLESS_COLLECT_LEAD[domain];
|
||
if (lead && !declined.has(domain)) {
|
||
next = makeFollowup({
|
||
method_id: PROBE_METHOD_ID[domain],
|
||
intent: "collect_method_evidence",
|
||
ask_theme: REVERSE_VERIFY_THEME[domain],
|
||
domain,
|
||
kind_hint: REVERSE_VERIFY_KIND[domain],
|
||
user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]),
|
||
source: "method_coverage",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!next) {
|
||
if (!relationshipCovered && !declined.has("relationship")) {
|
||
next = makeFollowup({
|
||
method_id: "d9_relationship",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "relationship_style",
|
||
domain: "relationship",
|
||
kind_hint: "relationship_start",
|
||
user_prompt_hint: collect(
|
||
"可以先说一段记得大概时间的感情变化,比如开始认真交往、分手或结婚。",
|
||
"D9",
|
||
"对照 D9 上升类型表(白羊主动热情、天蝎深刻占有等)只作校时方法,不是命运承诺。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (!careerCovered && !declined.has("career")) {
|
||
next = makeFollowup({
|
||
method_id: "d10_career",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "career_style",
|
||
domain: "career",
|
||
kind_hint: "career_entry",
|
||
user_prompt_hint: collect(
|
||
"可以先说一段记得大概时间的工作变化,比如入职、换工作或职责加重。同一件事会同时对照本命第 10 宫和 D10。",
|
||
"D10",
|
||
"可用 D10 事业类型表作校时对照。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (!familyCovered && !declined.has("family")) {
|
||
const familyCollect = datedCollectionProbe(input.evidenceCollectionProbes, "family");
|
||
next = makeFollowup({
|
||
method_id: "relatives",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "family_event",
|
||
domain: "family",
|
||
kind_hint: "family_event",
|
||
user_prompt_hint: collect(
|
||
"可以先问家里有没有结婚、添丁或住院这类记得住时间的事。同一件事会对照 D12 父母盘、D7 子女盘和 D3 兄弟盘。",
|
||
"D12 / D7 / D3",
|
||
"六亲用 Raman 六步:前三步可执行,后三步标方法论层。",
|
||
),
|
||
source: "method_coverage",
|
||
...collectionYearFields(familyCollect),
|
||
});
|
||
} else if (!occupationCovered) {
|
||
next = makeFollowup({
|
||
method_id: "occupation",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "occupation",
|
||
domain: "occupation",
|
||
kind_hint: "occupation_note",
|
||
user_prompt_hint: collect(
|
||
"你长期做什么工作?对照本命第 10 宫和 D10。",
|
||
"D1-H10 + D10",
|
||
"可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (
|
||
!meetsAcceptanceEventQuality(input.evidence)
|
||
&& !(stage === "d5_refine" && !hasConfirmedDomain(input.evidence, "education") && !declined.has("education"))
|
||
&& !(stage === "d9_refine" && !relationshipCovered && !declined.has("relationship"))
|
||
&& !(stage === "d10_refine" && !careerCovered && !declined.has("career"))
|
||
&& !((stage === "d4_refine" || stage === "theme_refine")
|
||
&& !hasConfirmedDomain(input.evidence, "relocation")
|
||
&& !declined.has("relocation"))
|
||
) {
|
||
next = makeFollowup({
|
||
method_id: "dasha_events",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "dated_event",
|
||
domain: null,
|
||
kind_hint: null,
|
||
user_prompt_hint: collect(
|
||
"再记一件记得大概时间的经历。当前用来区分候选的训练事件还不够。",
|
||
"本命 Dasha + 行运(方法1)",
|
||
"不要开始点选区分题。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (stage === "lagna_frame") {
|
||
next = makeFollowup({
|
||
method_id: "dasha_events",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "dated_event",
|
||
domain: null,
|
||
kind_hint: null,
|
||
user_prompt_hint: ask(
|
||
"窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。",
|
||
"本命上升 / Dasha",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if (stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) {
|
||
next = makeFollowup({
|
||
method_id: "d9_relationship",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "relationship_style",
|
||
domain: "relationship",
|
||
kind_hint: "relationship_change",
|
||
user_prompt_hint: ask(
|
||
"关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D9",
|
||
"对照 D9 上升类型表只作校时方法。",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if (stage === "d10_refine" && !declined.has("career")) {
|
||
next = makeFollowup({
|
||
method_id: "d10_career",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "career_style",
|
||
domain: "career",
|
||
kind_hint: "career_change",
|
||
user_prompt_hint: ask(
|
||
"事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D10",
|
||
"可用 D10 事业类型表作校时对照。",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if ((stage === "d4_refine" || stage === "theme_refine") && !declined.has("relocation")) {
|
||
next = makeFollowup({
|
||
method_id: "d4_home",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "home_change",
|
||
domain: "relocation",
|
||
kind_hint: "home_change",
|
||
user_prompt_hint: ask(
|
||
"居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D4",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if (stage === "d5_refine" && !declined.has("education")) {
|
||
next = makeFollowup({
|
||
method_id: "d5_education",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "education_style",
|
||
domain: "education",
|
||
kind_hint: "education_milestone",
|
||
user_prompt_hint: ask(
|
||
"成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D5 / D24",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else {
|
||
const d9 = input.observations?.find((item) => item.layer === "d9");
|
||
const d10 = input.observations?.find((item) => item.layer === "d10");
|
||
const d4 = input.observations?.find((item) => item.layer === "d4");
|
||
const d5 = input.observations?.find((item) => item.layer === "d5");
|
||
const d7 = input.observations?.find((item) => item.layer === "d7");
|
||
const d12 = input.observations?.find((item) => item.layer === "d12");
|
||
const d11 = input.observations?.find((item) => item.layer === "d11");
|
||
const d30 = input.observations?.find((item) => item.layer === "d30");
|
||
if (d9?.candidates_differ && !relationshipCovered && !declined.has("relationship")) {
|
||
next = makeFollowup({
|
||
method_id: "d9_relationship",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "relationship_style",
|
||
domain: "relationship",
|
||
kind_hint: "relationship_change",
|
||
user_prompt_hint: ask(
|
||
"当前候选在关系主题上仍分不开。按探针年份问感情前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D9",
|
||
"对照 D9 上升类型表只作校时方法。",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (d10?.candidates_differ && !declined.has("career")) {
|
||
next = makeFollowup({
|
||
method_id: "d10_career",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "career_style",
|
||
domain: "career",
|
||
kind_hint: "career_change",
|
||
user_prompt_hint: ask(
|
||
"当前候选在事业主题上仍分不开。按探针年份问事业前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D10",
|
||
"可用事业类型表作校时对照。",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (d4?.candidates_differ && !declined.has("relocation")) {
|
||
next = makeFollowup({
|
||
method_id: "d4_home",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "home_change",
|
||
domain: "relocation",
|
||
kind_hint: "home_change",
|
||
user_prompt_hint: ask(
|
||
"当前候选在居所主题上仍分不开。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D4",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (d5?.candidates_differ && !declined.has("education")) {
|
||
next = makeFollowup({
|
||
method_id: "d5_education",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "education_style",
|
||
domain: "education",
|
||
kind_hint: "education_milestone",
|
||
user_prompt_hint: ask(
|
||
"当前候选在学业或成就主题上仍分不开。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D5 / D24",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if ((d12?.candidates_differ || d7?.candidates_differ) && !declined.has("family")) {
|
||
next = makeFollowup({
|
||
method_id: "relatives",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "family_event",
|
||
domain: "family",
|
||
kind_hint: "family_event",
|
||
user_prompt_hint: ask(
|
||
"当前候选在家人主题上仍分不开。按探针年份问家人变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D12 / D7 / D3",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (d11?.candidates_differ && financeCovered && !declined.has("finance")) {
|
||
next = makeFollowup({
|
||
method_id: "d2_finance",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "finance_change",
|
||
domain: "finance",
|
||
kind_hint: "finance_change",
|
||
user_prompt_hint: ask(
|
||
"当前候选在财务主题上仍分不开。按探针年份问财务变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D2 / D11",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (d30?.candidates_differ && healthCovered && !declinedHealth(declined)) {
|
||
next = makeFollowup({
|
||
method_id: "d30_health",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "health_pressure",
|
||
domain: "health_pressure",
|
||
kind_hint: "self_health_event",
|
||
user_prompt_hint: ask(
|
||
"当前候选在健康压力主题上仍分不开。按探针年份问健康或压力变化是否发生,用来筛窗。这不是医学判断。不要问两套盘哪个更像。",
|
||
"D30",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (input.nakshatraBoundary?.near_boundary) {
|
||
next = makeFollowup({
|
||
method_id: "nakshatra_boundary",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "nakshatra_trait",
|
||
domain: null,
|
||
kind_hint: null,
|
||
user_prompt_hint: input.nakshatraBoundary.user_meaning
|
||
?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。",
|
||
source: "nakshatra_boundary",
|
||
});
|
||
} else if (input.holdoutValidation === "not_started") {
|
||
const prompt = input.oosBlindPrompts?.[0];
|
||
const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null);
|
||
if (prompt) {
|
||
next = makeFollowup({
|
||
method_id: "oos_blind",
|
||
intent: "out_of_sample_check",
|
||
ask_theme: "holdout",
|
||
domain: prompt.domain,
|
||
kind_hint: null,
|
||
user_prompt_hint: prompt.user_meaning,
|
||
source: "oos_blind",
|
||
}, false, true);
|
||
} else if (reserved) {
|
||
next = makeFollowup({
|
||
method_id: "holdout_validation",
|
||
intent: "out_of_sample_check",
|
||
ask_theme: "holdout",
|
||
domain: reserved.domain,
|
||
kind_hint: null,
|
||
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
|
||
source: "oos_blind",
|
||
}, false, true);
|
||
}
|
||
} else if (horaryStatus === "uncovered") {
|
||
next = makeFollowup({
|
||
method_id: "horary",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "horary",
|
||
domain: "horary",
|
||
kind_hint: "horary_query",
|
||
user_prompt_hint: collect(
|
||
"有没有第一次认真问起这件事的时间?",
|
||
"占问观察盘",
|
||
"有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const deferAdoption = sessionOutcome === "adopt_representative"
|
||
|| sessionOutcome === "validated_range"
|
||
|| sessionOutcome === "exact_minute_confirmed"
|
||
|| sessionOutcome === "provisional_range"
|
||
|| sessionOutcome === "provisional_range_user_stopped"
|
||
|| sessionOutcome === "completed_with_range";
|
||
return {
|
||
methods,
|
||
next_followup: deferAdoption ? null : next,
|
||
deferred_followup: deferAdoption ? next : null,
|
||
session_outcome: sessionOutcome,
|
||
stop_domain_rotation: true,
|
||
do_not_poll: DO_NOT_POLL,
|
||
not_in_rotation: NOT_IN_ROTATION,
|
||
};
|
||
}
|
||
|
||
export function projectRectificationChoiceCard(
|
||
input: Parameters<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,
|
||
});
|
||
}
|