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