Staging gate 2558 failed because leftover tests still expected D9/D10 or leftover collect after the pool emptied. Discriminate sessions still deliver; dated precision cards still ask. Co-authored-by: Cursor <cursoragent@cursor.com>
2842 lines
110 KiB
TypeScript
2842 lines
110 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 S1 pool collect and occupation
|
||
* before the adopt early-exit. Yearless contrast is not rewritten into a
|
||
* domain-wheel collect after the training gate (BUG-648). 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 dated dasha distinguish probes are asked first. Yearless
|
||
* D9/D10 style and nakshatra_boundary stay out of the dated pool and
|
||
* never occupy ask_candidate_discriminator (BUG-629, BUG-651). When
|
||
* the dated pool is empty after the training gate, deliver the range.
|
||
* 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,
|
||
collectQuestionForDomain,
|
||
withFirstDatedCollectInvite,
|
||
} from "../user-copy.ts";
|
||
import {
|
||
collectionQuestionPool,
|
||
isInviteCollectTopic,
|
||
type CollectionPoolItem,
|
||
} from "./collection-question-pool.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,
|
||
askedKeysForSameYearDedup,
|
||
isEventQualityProbe,
|
||
sameYearProbeAsked,
|
||
SEMANTIC_YEAR_KEY,
|
||
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 { probeBelowAdultFloor } from "./adult-floor.ts";
|
||
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 { isEventQualityProbe, sameYearProbeAsked };
|
||
|
||
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;
|
||
invite_more_once?: boolean;
|
||
spoken_prompt?: string;
|
||
collection_key?: string;
|
||
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: Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">,
|
||
): 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 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;
|
||
}
|
||
|
||
export function existenceProbeAsked(
|
||
askedKeys: ReadonlySet<string> | readonly string[],
|
||
domain: string,
|
||
year: number,
|
||
): boolean {
|
||
if (!domain || !year) return false;
|
||
if (sameYearProbeAsked(askedKeys, domain, year)) return true;
|
||
const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys);
|
||
const nearby = existenceNearbyYears(domain);
|
||
if (nearby <= 0) return false;
|
||
for (const key of asked) {
|
||
const match = key.match(SEMANTIC_YEAR_KEY);
|
||
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 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[],
|
||
evidence?: readonly MethodFollowupEvidence[],
|
||
declinedTopics?: readonly Readonly<Record<string, unknown>>[],
|
||
): boolean {
|
||
if (evidence) {
|
||
if (trainingScoreableGate(evidence).open) return false;
|
||
return nextCollectionFollowup(evidence, declinedTopics ?? []) != null;
|
||
}
|
||
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 topicStatus(topic: Readonly<Record<string, unknown>>): string {
|
||
return typeof topic.status === "string" ? topic.status : "";
|
||
}
|
||
|
||
function declinedDomains(
|
||
topics: readonly Readonly<Record<string, unknown>>[],
|
||
options: { includeSkipped?: boolean } = {},
|
||
): Set<string> {
|
||
const includeSkipped = options.includeSkipped !== false;
|
||
const domains = new Set<string>();
|
||
for (const topic of topics) {
|
||
if (isOpeningOtherCollectTopic(topic) || isInviteCollectTopic(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;
|
||
if (!includeSkipped && topicStatus(topic) === "skipped") 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 ?? "";
|
||
const exact = askedKeys.has(semantic)
|
||
|| (split !== "" && askedKeys.has(split));
|
||
if (isEventQualityProbe(probe)) return exact;
|
||
return exact
|
||
|| 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 ?? [], { includeSkipped: false }),
|
||
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))
|
||
|| sameYearProbeAsked(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 yearAskedKeys = askedKeysForSameYearDedup(askedKeys, probe);
|
||
if (sameYearProbeAsked(yearAskedKeys, probe.domain, probe.year)) {
|
||
return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "same_year_asked") };
|
||
}
|
||
const layer = vargaLayerFromSemanticKey(key);
|
||
const asked = askedKeys.has(key)
|
||
|| Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash))
|
||
|| existenceProbeAsked(yearAskedKeys, 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 yearAskedKeys = askedKeysForSameYearDedup(askedKeys, working);
|
||
if (sameYearProbeAsked(yearAskedKeys, working.domain, working.year)) {
|
||
return {
|
||
row: null,
|
||
dropped: droppedFromProbe(working.semanticKey, working.informationGain, "same_year_asked"),
|
||
};
|
||
}
|
||
const layer = vargaLayerFromSemanticKey(working.semanticKey);
|
||
const asked = askedKeys.has(working.semanticKey)
|
||
|| askedKeys.has(working.candidateSplitHash)
|
||
|| askedKeys.has(working.probeId)
|
||
|| existenceProbeAsked(yearAskedKeys, working.domain ?? "", working.year ?? 0)
|
||
|| (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: "生病、受伤或压力特别大",
|
||
};
|
||
|
||
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 false;
|
||
const year = row.eventProbe?.year ?? row.contrastProbe?.year ?? 0;
|
||
return year > 0;
|
||
}
|
||
|
||
function isYearlessPersonalityRow(row: RankedDiscriminator): boolean {
|
||
return discriminatorChoiceKind(row) === "varga_style";
|
||
}
|
||
|
||
/** Dated reverse-inference only. D9/D10 / nakshatra never count (BUG-651). */
|
||
function followupLocksDatedPeriod(followup: MethodFollowup | null | undefined): boolean {
|
||
if (!followup) return false;
|
||
if (followup.choice_kind === "varga_style") return false;
|
||
if (followup.source === "nakshatra_boundary") return false;
|
||
if ((followup.probe_year ?? 0) > 0) return true;
|
||
const period = followup.choice_frame?.period ?? "";
|
||
return /(?:19|20)\d{2}/.test(period);
|
||
}
|
||
|
||
export function yearlessPersonalityCanAsk(input: {
|
||
datedCount: number;
|
||
candidatesSeparated: boolean;
|
||
topCandidateTimes?: readonly string[];
|
||
}): boolean {
|
||
if (input.datedCount > 0 || input.candidatesSeparated) return false;
|
||
const times = input.topCandidateTimes;
|
||
if (times !== undefined && times.length < 2) return false;
|
||
return true;
|
||
}
|
||
|
||
function emptyRankedCatalog(): {
|
||
locked: RankedDiscriminator[];
|
||
personality: RankedDiscriminator[];
|
||
yearless: RankedDiscriminator[];
|
||
dropped: DroppedProbe[];
|
||
} {
|
||
return { locked: [], personality: [], yearless: [], dropped: [] };
|
||
}
|
||
|
||
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[]; personality: 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)),
|
||
personality: sorted.filter((row) => isYearlessPersonalityRow(row)),
|
||
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)),
|
||
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,
|
||
evidence: readonly MethodFollowupEvidence[] = [],
|
||
): 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;
|
||
if (followup.spoken_prompt?.trim()) return followup.spoken_prompt.trim();
|
||
const domain = followup.domain ?? "";
|
||
const openingOther = followup.source === "method_coverage"
|
||
&& followup.method_id === "dasha_events"
|
||
&& domain === "other"
|
||
&& followup.collect_retry !== true
|
||
&& !evidence.some(isConfirmedDated);
|
||
if (domain === "other") {
|
||
return openingOther ? GENERIC_COLLECT_QUESTION : null;
|
||
}
|
||
const mapped = followup.collect_retry === true
|
||
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
|
||
: collectQuestionForDomain(domain);
|
||
if (!mapped) return null;
|
||
const dated = (DATED_COLLECT_ORDER as readonly string[]).includes(domain);
|
||
if (followup.invite_more_once && dated && followup.collect_retry !== true) {
|
||
return withFirstDatedCollectInvite(mapped);
|
||
}
|
||
return mapped;
|
||
}
|
||
|
||
export const DATED_COLLECT_ORDER = [
|
||
"family",
|
||
"education",
|
||
"finance",
|
||
"relocation",
|
||
"health_pressure",
|
||
"career",
|
||
"relationship",
|
||
] as const;
|
||
|
||
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 datedCollectAlreadyAsked(
|
||
declined: ReadonlySet<string>,
|
||
topics: readonly Readonly<Record<string, unknown>>[] = [],
|
||
): boolean {
|
||
if (DATED_COLLECT_ORDER.some((domain) => declined.has(domain))) return true;
|
||
return DATED_COLLECT_ORDER.some((domain) => domainCollectFocusAsked(topics, domain));
|
||
}
|
||
|
||
export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup {
|
||
if (item.kind === "invite") {
|
||
return {
|
||
method_id: "dasha_events",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "dated_event",
|
||
domain: "other",
|
||
kind_hint: "invite_more",
|
||
user_prompt_hint: item.prompt,
|
||
must_not_label: false,
|
||
choice_frame: null,
|
||
source: "method_coverage",
|
||
spoken_prompt: item.prompt,
|
||
collection_key: item.key,
|
||
};
|
||
}
|
||
const domain = item.domain;
|
||
const methodId = domain in PROBE_METHOD_ID
|
||
? PROBE_METHOD_ID[domain as keyof typeof PROBE_METHOD_ID]
|
||
: "dasha_events";
|
||
const theme = domain in REVERSE_VERIFY_THEME
|
||
? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME]
|
||
: "dated_event";
|
||
const kindHint = item.kind === "anchor" && item.targetKind && item.year != null
|
||
? `anchor:${item.targetKind}:${item.year}`
|
||
: item.kind === "generic"
|
||
? `generic:${domain}`
|
||
: domain in REVERSE_VERIFY_KIND
|
||
? REVERSE_VERIFY_KIND[domain as keyof typeof REVERSE_VERIFY_KIND]
|
||
: null;
|
||
return {
|
||
method_id: methodId,
|
||
intent: "collect_method_evidence",
|
||
ask_theme: theme,
|
||
domain,
|
||
kind_hint: kindHint,
|
||
user_prompt_hint: item.prompt,
|
||
must_not_label: false,
|
||
choice_frame: null,
|
||
source: "method_coverage",
|
||
spoken_prompt: item.prompt,
|
||
collection_key: item.key,
|
||
...(item.year ? { probe_year: item.year, year_label: `${item.year} 年` } : {}),
|
||
};
|
||
}
|
||
|
||
export function nextCollectionFollowup(
|
||
evidence: readonly MethodFollowupEvidence[],
|
||
declinedTopics: readonly Readonly<Record<string, unknown>>[] = [],
|
||
): MethodFollowup | null {
|
||
const top = collectionQuestionPool(evidence, declinedTopics)[0];
|
||
return top ? followupFromPoolItem(top) : null;
|
||
}
|
||
|
||
export function nextDatedCollectFollowup(
|
||
evidence: readonly MethodFollowupEvidence[],
|
||
declined: ReadonlySet<string>,
|
||
answeredYes: ReadonlySet<string> = new Set(),
|
||
options?: {
|
||
askedDatedCollect?: boolean;
|
||
collectionProbes?: readonly DiscriminatingEventProbe[];
|
||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||
},
|
||
): MethodFollowup | null {
|
||
const topics = [
|
||
...(options?.declinedTopics ?? []),
|
||
...[...declined].map((domain) => ({ target_domain: domain, status: "declined" })),
|
||
...[...answeredYes].map((domain) => ({ target_domain: domain, status: "resolved" })),
|
||
];
|
||
return nextCollectionFollowup(evidence, topics);
|
||
}
|
||
|
||
const REMAINING_EVIDENCE_COLLECT_DOMAINS = new Set<string>([
|
||
...DATED_COLLECT_ORDER,
|
||
"occupation",
|
||
]);
|
||
|
||
/** Spoken collect that can still add distinguishing evidence. Includes the S1 pool. */
|
||
export function isRemainingEvidenceCollect(
|
||
followup: {
|
||
intent: string;
|
||
domain: string | null;
|
||
date_reliability_evidence_id?: string | null;
|
||
collection_key?: string;
|
||
kind_hint?: string | null;
|
||
} | null | undefined,
|
||
): boolean {
|
||
if (!followup || followup.intent !== "collect_method_evidence") return false;
|
||
if (followup.date_reliability_evidence_id) return false;
|
||
const key = followup.collection_key ?? "";
|
||
if (
|
||
key.startsWith("collect:invite:")
|
||
|| key.startsWith("collect:anchor:")
|
||
|| key.startsWith("collect:generic:")
|
||
) return true;
|
||
const hint = followup.kind_hint ?? "";
|
||
if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:")) return true;
|
||
return typeof followup.domain === "string"
|
||
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
|
||
}
|
||
|
||
/** Remaining spoken collect that can still add a dated event in an uncovered domain. */
|
||
export function remainingEvidenceCollectStillOpen(
|
||
followup: Pick<MethodFollowup, "intent" | "domain" | "date_reliability_evidence_id"> | null | undefined,
|
||
evidence: readonly MethodFollowupEvidence[] = [],
|
||
declinedTopics: readonly Readonly<Record<string, unknown>>[] = [],
|
||
): boolean {
|
||
if (!isRemainingEvidenceCollect(followup) || !followup?.domain) return false;
|
||
const declined = declinedDomains(declinedTopics);
|
||
if (followup.domain === "occupation") {
|
||
return !hasConfirmedDomain(evidence, "occupation") && !declined.has("occupation");
|
||
}
|
||
if ((DATED_COLLECT_ORDER as readonly string[]).includes(followup.domain)) {
|
||
return !datedCollectDomainBlocked(
|
||
followup.domain as (typeof DATED_COLLECT_ORDER)[number],
|
||
evidence,
|
||
declined,
|
||
);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function holdoutOccupiedDomains(evidence: readonly MethodFollowupEvidence[]): Set<string> {
|
||
const occupied = new Set(
|
||
evidence
|
||
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
|
||
.map((item) => item.domain),
|
||
);
|
||
if (hasConfirmedHealth(evidence) || occupied.has("health") || occupied.has("health_pressure")) {
|
||
occupied.add("health");
|
||
occupied.add("health_pressure");
|
||
}
|
||
return occupied;
|
||
}
|
||
|
||
export function exhaustionSpokenCollectFollowup(input: {
|
||
evidence: readonly MethodFollowupEvidence[];
|
||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||
answeredProbes?: readonly AnsweredProbeCoverageRow[];
|
||
eventProbes?: readonly DiscriminatingEventProbe[];
|
||
}): MethodFollowup | null {
|
||
if (trainingScoreableGate(input.evidence).open) return null;
|
||
return nextCollectionFollowup(input.evidence, input.declinedTopics ?? []);
|
||
}
|
||
|
||
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 parseCollectFocusQuestionId(questionId: string | null | undefined): {
|
||
domain: string;
|
||
} | null {
|
||
if (!questionId) return null;
|
||
const match = /^collect:([^:]+):/.exec(questionId.trim());
|
||
const domain = match?.[1]?.trim() ?? "";
|
||
if (!domain) return null;
|
||
return { domain };
|
||
}
|
||
|
||
export function ledgerHasConfirmedDatedEvent(
|
||
evidence: readonly Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">[],
|
||
): boolean {
|
||
return evidence.some(isConfirmedDated);
|
||
}
|
||
|
||
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",
|
||
...(followup.spoken_prompt ? { spoken_prompt: followup.spoken_prompt } : {}),
|
||
...(followup.collection_key ? { collection_key: followup.collection_key } : {}),
|
||
};
|
||
}
|
||
|
||
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, input.evidence) : 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 || !USER_COLLECT_QUESTION[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],
|
||
source: "method_coverage",
|
||
};
|
||
}
|
||
|
||
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
|
||
const next = new Set(declined);
|
||
if (declinedHealth(declined)) {
|
||
next.add("health");
|
||
next.add("health_pressure");
|
||
}
|
||
return next;
|
||
}
|
||
|
||
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 = holdoutOccupiedDomains(input.evidence);
|
||
const blocked = declinedForHoldout(declined);
|
||
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
|
||
Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||
)) ?? null;
|
||
const reserved = (input.holdoutEvents ?? []).find((item) => (
|
||
item.year != null && !occupied.has(item.domain) && !blocked.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);
|
||
const inviteMoreOnce = base.intent === "collect_method_evidence"
|
||
&& typeof base.domain === "string"
|
||
&& (DATED_COLLECT_ORDER as readonly string[]).includes(base.domain)
|
||
&& !collectRetry
|
||
&& !datedCollectAlreadyAsked(declinedDomains(input.declinedTopics ?? []), askedRows);
|
||
return {
|
||
...base,
|
||
...(collectRetry ? { collect_retry: true } : {}),
|
||
...(inviteMoreOnce ? { invite_more_once: true } : { invite_more_once: false }),
|
||
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 reverseVerifyDeclined = declinedDomains(input.declinedTopics ?? [], { includeSkipped: false });
|
||
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"
|
||
? emptyRankedCatalog()
|
||
: 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,
|
||
})
|
||
: emptyRankedCatalog();
|
||
const rankedDiscriminators = rankedCatalog.locked;
|
||
const personalityDiscriminators = rankedCatalog.personality;
|
||
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")
|
||
|| Boolean(focus.questionId?.startsWith("collect:other:") && dashaCovered)
|
||
),
|
||
);
|
||
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: parseCollectFocusQuestionId(focus.questionId)?.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,
|
||
reverseVerifyDeclined,
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
// BUG-651: personality / nakshatra stay optional after range delivery.
|
||
// They never occupy the discriminator slot, even when the dated pool is empty.
|
||
for (const ranked of personalityDiscriminators) {
|
||
extraDropped.push(droppedFromProbe(
|
||
ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style",
|
||
ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score,
|
||
"yearless_deferred",
|
||
));
|
||
}
|
||
if (input.nakshatraProbe) {
|
||
extraDropped.push(droppedFromProbe(
|
||
input.nakshatraProbe.semantic_key,
|
||
input.nakshatraProbe.information_gain,
|
||
"yearless_deferred",
|
||
));
|
||
}
|
||
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",
|
||
});
|
||
}
|
||
const collectingByPool = !meetsAcceptanceEventQuality(input.evidence);
|
||
if (!next && dashaCovered && collectingByPool) {
|
||
const poolNext = nextCollectionFollowup(input.evidence, [
|
||
...(input.declinedTopics ?? []),
|
||
...(input.closedCollectFocuses ?? []),
|
||
]);
|
||
if (poolNext) next = makeFollowup(poolNext);
|
||
}
|
||
// S1 ranks invite / anchors / generics. Do not convert leftover yearless
|
||
// contrast back into a domain wheel after the training gate (BUG-648).
|
||
// BUG-651: nakshatra is already deferred above and must not take this slot.
|
||
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 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 renderableYearless: MethodFollowup | null = null;
|
||
const takeRenderableDistinguish = (item: MethodFollowup | null): MethodFollowup | null => {
|
||
if (!item) return null;
|
||
if (item.intent !== "distinguish_candidates" || item.choice_frame) return item;
|
||
extraDropped.push({
|
||
semantic_key: item.semantic_key ?? item.method_id,
|
||
information_gain: item.information_gain ?? 0,
|
||
reason: "frameless_distinguish",
|
||
});
|
||
return null;
|
||
};
|
||
const pendingHoldout = input.holdoutValidation === "not_started"
|
||
? holdoutFollowupFor(input, declined)
|
||
: null;
|
||
if (!next) {
|
||
const precisionCard = takeRenderableDistinguish(precisionStageFollowup());
|
||
const datedPoolEmpty = rankedDiscriminators.length === 0;
|
||
const datedPrecision = Boolean(
|
||
precisionCard?.choice_frame && followupLocksDatedPeriod(precisionCard),
|
||
);
|
||
if (collectingByPool) {
|
||
next = null;
|
||
} else if (datedPrecision) {
|
||
next = precisionCard;
|
||
} else if (
|
||
datedPoolEmpty
|
||
&& meetsAcceptanceEventQuality(input.evidence)
|
||
&& sessionOutcome === "discriminate_candidates"
|
||
) {
|
||
// BUG-651: discriminating with no dated probe must not pick yearless
|
||
// personality or leftover method collect as the next discriminator.
|
||
next = null;
|
||
} 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 (!occupationCovered && 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");
|
||
}
|
||
} else if (pendingHoldout) {
|
||
next = makeFollowup(pendingHoldout, false);
|
||
} 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) {
|
||
extraDropped.push({
|
||
semantic_key: next.semantic_key ?? next.method_id,
|
||
information_gain: next.information_gain ?? 0,
|
||
reason: next.source === "nakshatra_boundary" ? "not_renderable" : "frameless_distinguish",
|
||
});
|
||
next = null;
|
||
}
|
||
if (!next) {
|
||
const leftoverCollect = exhaustionSpokenCollectFollowup({
|
||
evidence: input.evidence,
|
||
declinedTopics: [
|
||
...(input.declinedTopics ?? []),
|
||
...(input.closedCollectFocuses ?? []),
|
||
],
|
||
answeredProbes: input.answeredProbes,
|
||
eventProbes: input.eventProbes,
|
||
});
|
||
if (leftoverCollect) {
|
||
next = makeFollowup(leftoverCollect);
|
||
} else if (pendingHoldout) {
|
||
next = makeFollowup(pendingHoldout, false);
|
||
}
|
||
}
|
||
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,
|
||
});
|
||
}
|