Docker next build rejected 909de8b8 because engine probe outcomes, reverse-verify source checks, and a nullable window scan failed TypeScript.
Co-authored-by: Cursor <cursoragent@cursor.com>
914 lines
35 KiB
TypeScript
914 lines
35 KiB
TypeScript
/**
|
||
* Eight-method follow-up routing for birth-time rectification.
|
||
*
|
||
* Web used to round-robin SQL missing_evidence_categories (relocation /
|
||
* health / finance). Local skill asks by method layer. This plan is the
|
||
* server's next question. It still only produces candidates, never a
|
||
* confirmed unique minute.
|
||
*
|
||
* Policy map (classic eight):
|
||
* 1. Dasha + dated events — any confirmed dated event
|
||
* 2. D9 relationship — confirmed relationship evidence; D9 type table is a
|
||
* rectification method, not a fate promise
|
||
* 3. D10 career — confirmed dated career evidence; same event also scores D1 10th house
|
||
* 4. Relatives — confirmed family evidence (D12 + D7 + D3)
|
||
* 5. Appearance / constitution — skipped; never asked
|
||
* 6. Birthmarks / scars — skipped; never asked
|
||
* 7. Occupation / 10th house — separate from dated career events; D10 type table allowed.
|
||
* A draft/confirmed occupation_note without a date still covers this layer.
|
||
* 8. Horary — ask once for the first question time; recast if given; never blocks cards
|
||
*
|
||
* Relocation stays out of domain rotation and is only asked at d4_refine.
|
||
* Finance/health score if volunteered; they are not method-layer rotation.
|
||
* Method coverage finishes before repeating a precision-stage ask.
|
||
* Appearance and marks are skipped_by_policy. Horary does not block offering
|
||
* time cards. Occupation does block cards until a note exists.
|
||
* Method coverage asks for dated events in natural language.
|
||
* After the first dated event, remaining dasha conflict probes
|
||
* (year/activation differences) are asked before more method rotation
|
||
* and they block offering time cards so the window can be filtered.
|
||
* Once blocking methods are covered and propose_allowed, offer a
|
||
* representative time even if probes or horary remain.
|
||
* A/B/C/D choice frames attach only when candidates already diverge
|
||
* (event probes, precision stage, varga observation, nakshatra, or holdout).
|
||
*/
|
||
|
||
import {
|
||
buildChoiceFrame,
|
||
parseAgentChoiceCopy,
|
||
mergeChoiceCard,
|
||
type RectificationChoiceCard,
|
||
type RectificationChoiceFrame,
|
||
} from "./choice-card.ts";
|
||
import { sessionOutcomeFromGate, type SessionOutcomeKind } from "./confirmation-gate.ts";
|
||
import type {
|
||
DiscriminatingEventProbe,
|
||
NakshatraBoundary,
|
||
OosBlindPrompt,
|
||
PrecisionStageId,
|
||
} from "./refinement-packet";
|
||
import type { InternalVargaObservation } from "./varga-observations";
|
||
|
||
export const METHOD_FOLLOWUP_IDS = [
|
||
"dasha_events",
|
||
"d9_relationship",
|
||
"d10_career",
|
||
"relatives",
|
||
"appearance",
|
||
"marks",
|
||
"occupation",
|
||
"horary",
|
||
] as const;
|
||
|
||
export type MethodFollowupId = (typeof METHOD_FOLLOWUP_IDS)[number];
|
||
|
||
export type MethodCoverageStatus = "covered" | "uncovered" | "skipped_by_policy";
|
||
|
||
export type MethodCoverage = Readonly<{
|
||
method_id: MethodFollowupId;
|
||
status: MethodCoverageStatus;
|
||
}>;
|
||
|
||
export type MethodFollowup = Readonly<{
|
||
method_id: "dasha_events" | "d9_relationship" | "d10_career" | "d4_home" | "d5_education" | "relatives" | "d2_finance" | "d30_health" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_boundary" | "oos_blind" | "reverse_verify";
|
||
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";
|
||
domain: string | null;
|
||
kind_hint: string | null;
|
||
user_prompt_hint: string;
|
||
must_not_label: boolean;
|
||
choice_frame: RectificationChoiceFrame | null;
|
||
source: "active_focus" | "method_coverage" | "varga_observation" | "precision_stage" | "nakshatra_boundary" | "oos_blind" | "reverse_verify" | "event_probe";
|
||
information_gain?: number;
|
||
semantic_key?: string;
|
||
candidate_split_hash?: string;
|
||
probe_year?: number;
|
||
}>;
|
||
|
||
export type MethodFollowupPlan = Readonly<{
|
||
methods: readonly MethodCoverage[];
|
||
next_followup: MethodFollowup | null;
|
||
deferred_followup: MethodFollowup | null;
|
||
session_outcome: SessionOutcomeKind;
|
||
stop_domain_rotation: true;
|
||
do_not_poll: readonly [];
|
||
not_in_rotation: readonly ["relocation"];
|
||
}>;
|
||
|
||
export type MethodFollowupEvidence = Readonly<{
|
||
status: string;
|
||
domain: string;
|
||
datePrecision: string;
|
||
occurredFrom: string | null;
|
||
occurredTo: string | null;
|
||
eventKind?: string | null;
|
||
summary?: string | null;
|
||
}>;
|
||
|
||
export type MethodFollowupFocus = Readonly<{
|
||
intent: string;
|
||
targetDomain: string | null;
|
||
targetKind: string | null;
|
||
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
|
||
}>;
|
||
|
||
const DO_NOT_POLL = [] as const;
|
||
const NOT_IN_ROTATION = ["relocation"] as const;
|
||
const BLOCKING_COVERAGE_IDS = new Set<MethodFollowupId>([
|
||
"dasha_events",
|
||
"d9_relationship",
|
||
"d10_career",
|
||
"relatives",
|
||
"occupation",
|
||
]);
|
||
|
||
function isConfirmedDated(item: MethodFollowupEvidence): boolean {
|
||
return item.status === "confirmed"
|
||
&& item.datePrecision !== "unknown"
|
||
&& Boolean(item.occurredFrom || item.occurredTo);
|
||
}
|
||
|
||
function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: string): boolean {
|
||
return evidence.some((item) => item.status === "confirmed" && item.domain === domain);
|
||
}
|
||
|
||
function evidenceYear(item: MethodFollowupEvidence): number | null {
|
||
const raw = item.occurredFrom || item.occurredTo;
|
||
if (!raw || raw.length < 4 || !/^\d{4}/.test(raw)) return null;
|
||
const year = Number(raw.slice(0, 4));
|
||
return year >= 1900 && year <= 2100 ? year : null;
|
||
}
|
||
|
||
function hasDatedEvidenceInYear(
|
||
evidence: readonly MethodFollowupEvidence[],
|
||
domain: string,
|
||
year: number,
|
||
): boolean {
|
||
return evidence.some((item) =>
|
||
(item.status === "confirmed" || item.status === "draft" || item.status === "pending_confirmation")
|
||
&& item.domain === domain
|
||
&& evidenceYear(item) === year
|
||
);
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
function blockingMethodsCovered(methods: readonly MethodCoverage[]): boolean {
|
||
return !methods.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered");
|
||
}
|
||
|
||
function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean {
|
||
return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health");
|
||
}
|
||
|
||
function declinedHealth(declined: Set<string>): boolean {
|
||
return declined.has("health") || declined.has("health_pressure");
|
||
}
|
||
|
||
function declinedDomains(
|
||
topics: readonly Readonly<Record<string, unknown>>[],
|
||
): Set<string> {
|
||
const domains = new Set<string>();
|
||
for (const topic of topics) {
|
||
const domain = typeof topic.target_domain === "string"
|
||
? topic.target_domain
|
||
: typeof topic.targetDomain === "string"
|
||
? topic.targetDomain
|
||
: null;
|
||
if (domain) domains.add(domain);
|
||
}
|
||
return domains;
|
||
}
|
||
|
||
const MAX_REVERSE_VERIFY = 2;
|
||
|
||
const REVERSE_VERIFY_THEME = {
|
||
education: "education_style",
|
||
relocation: "home_change",
|
||
relationship: "relationship_style",
|
||
career: "career_style",
|
||
family: "family_event",
|
||
finance: "finance_change",
|
||
health_pressure: "health_pressure",
|
||
} as const;
|
||
|
||
const REVERSE_VERIFY_KIND = {
|
||
education: "education_milestone",
|
||
relocation: "home_change",
|
||
relationship: "relationship_change",
|
||
career: "career_change",
|
||
family: "family_event",
|
||
finance: "finance_change",
|
||
health_pressure: "self_health_event",
|
||
} as const;
|
||
|
||
const REVERSE_VERIFY_VARGA = {
|
||
education: "D5 / D24",
|
||
relocation: "D4",
|
||
relationship: "D9",
|
||
career: "D10",
|
||
family: "D12 / D7 / D3",
|
||
finance: "D2 / D11",
|
||
health_pressure: "D30",
|
||
} as const;
|
||
|
||
const PROBE_METHOD_ID = {
|
||
education: "d5_education",
|
||
relocation: "d4_home",
|
||
relationship: "d9_relationship",
|
||
career: "d10_career",
|
||
family: "relatives",
|
||
finance: "d2_finance",
|
||
health_pressure: "d30_health",
|
||
} as const;
|
||
|
||
const CONFLICT_PROBE_SOURCES = new Set<string>([
|
||
"dasha_boundary",
|
||
"dasha_activation",
|
||
]);
|
||
|
||
function remainingReverseVerifyProbes(
|
||
probes: readonly DiscriminatingEventProbe[] | undefined,
|
||
evidence: readonly MethodFollowupEvidence[],
|
||
declined: ReadonlySet<string>,
|
||
): DiscriminatingEventProbe[] {
|
||
const dasha: DiscriminatingEventProbe[] = [];
|
||
const fallback: DiscriminatingEventProbe[] = [];
|
||
for (const probe of probes ?? []) {
|
||
if (probe.source === "known_event_quality" || probe.role === "distinguish") continue;
|
||
if (declined.has(probe.domain)) continue;
|
||
if (hasDatedEvidenceInYear(evidence, probe.domain, probe.year)) continue;
|
||
if (CONFLICT_PROBE_SOURCES.has(probe.source)) {
|
||
dasha.push(probe);
|
||
} else {
|
||
fallback.push(probe);
|
||
}
|
||
}
|
||
return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY);
|
||
}
|
||
|
||
function remainingConflictProbes(
|
||
probes: readonly DiscriminatingEventProbe[] | undefined,
|
||
evidence: readonly MethodFollowupEvidence[],
|
||
declined: ReadonlySet<string>,
|
||
askedKeys: ReadonlySet<string> = new Set(),
|
||
): DiscriminatingEventProbe[] {
|
||
const rows: DiscriminatingEventProbe[] = [];
|
||
for (const probe of probes ?? []) {
|
||
if (!CONFLICT_PROBE_SOURCES.has(probe.source)) continue;
|
||
if (declined.has(probe.domain)) continue;
|
||
if (hasDatedEvidenceInYear(evidence, probe.domain, probe.year)) continue;
|
||
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
|
||
const split = probe.candidate_split_hash ?? "";
|
||
if (askedKeys.has(semantic) || (split && askedKeys.has(split))) continue;
|
||
rows.push(probe);
|
||
}
|
||
return rows
|
||
.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))
|
||
.slice(0, MAX_REVERSE_VERIFY);
|
||
}
|
||
|
||
function coverage(
|
||
methodId: MethodFollowupId,
|
||
status: MethodCoverageStatus,
|
||
): MethodCoverage {
|
||
return { method_id: methodId, status };
|
||
}
|
||
|
||
function collectHint(why: string, varga: string, extra = ""): string {
|
||
return `${why}本题绑定 ${varga}。${extra}用自然语言问一件带大概年份的经历。set-focus 不要写 expectedAnswerSchema.choice,界面不出点选卡。允许模糊年份。`.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function agentHint(why: string, varga: string, extra = ""): string {
|
||
return `${why}本题绑定 ${varga}。${extra}根据 choice_frame 自己写题干和 A/B/C/D,经 set-focus.expectedAnswerSchema.choice 交给点选卡。题干由你写成自然语言;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份,不要照抄 hint。A 是这件事大概就在那段时间,B 是有类似但年份不对或不够重大,C 是没有明显发生,D 是不记得。正文不要复述选项。`.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
export function shouldAttachChoiceFrame(
|
||
item: Pick<MethodFollowup, "intent" | "ask_theme" | "source">,
|
||
evidence: readonly MethodFollowupEvidence[] = [],
|
||
): boolean {
|
||
if (item.intent === "out_of_sample_check" || item.source === "oos_blind") return true;
|
||
if (item.intent === "reverse_verify" || item.source === "reverse_verify") return true;
|
||
if (item.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 type NextUserActionId =
|
||
| "adopt_representative"
|
||
| "score_now"
|
||
| "record_stated_events"
|
||
| "ask_method_followup"
|
||
| "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">;
|
||
user_meaning: string;
|
||
};
|
||
}>;
|
||
|
||
function action(
|
||
id: NextUserAction["on_user_stop"]["id"],
|
||
user_meaning: string,
|
||
): NextUserAction["on_user_stop"] {
|
||
return { id, user_meaning };
|
||
}
|
||
|
||
const USER_STOP_PATTERN = /暂时想不到了|没有更多|先这样/;
|
||
|
||
export function latestUserStoppedCollecting(
|
||
turns: readonly Readonly<{ role: string; text: string | null }>[],
|
||
): boolean {
|
||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||
const turn = turns[index];
|
||
if (turn.role !== "user") continue;
|
||
const text = turn.text?.trim() ?? "";
|
||
if (!text) continue;
|
||
return USER_STOP_PATTERN.test(text);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function isOfferBlockingFollowup(
|
||
followup: MethodFollowup | null,
|
||
methods?: readonly MethodCoverage[],
|
||
): boolean {
|
||
if (methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered")) {
|
||
return true;
|
||
}
|
||
if (!followup) return false;
|
||
if (followup.source === "event_probe") {
|
||
if (methods == null) return true;
|
||
return (followup.information_gain ?? 0) >= 0.08;
|
||
}
|
||
if (followup.source !== "method_coverage") return false;
|
||
return BLOCKING_COVERAGE_IDS.has(followup.method_id as MethodFollowupId);
|
||
}
|
||
|
||
export function conversationalSessionOutcome(input: {
|
||
selectionAllowed: boolean;
|
||
proposeAllowed: boolean;
|
||
confirmationAllowed: boolean;
|
||
nextFollowup: MethodFollowup | null;
|
||
methods?: readonly MethodCoverage[];
|
||
userStopped?: boolean;
|
||
}): SessionOutcomeKind {
|
||
return sessionOutcomeFromGate({
|
||
selectionAllowed: input.selectionAllowed,
|
||
proposeAllowed: input.proposeAllowed,
|
||
confirmationAllowed: input.confirmationAllowed,
|
||
interviewOpen: isOfferBlockingFollowup(input.nextFollowup, input.methods),
|
||
userStopped: input.userStopped,
|
||
}).kind;
|
||
}
|
||
|
||
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",
|
||
"本轮已有代表性候选时间。说明本会话以代表性时间收口,不确认唯一分钟,请用户采用下方时间卡片;采用后才用该时间看盘。不要只说记下了以后再说。",
|
||
);
|
||
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 };
|
||
}
|
||
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.selectionAllowed ? adopt : explain,
|
||
};
|
||
}
|
||
return { id: explain.id, user_meaning: explain.user_meaning, on_user_stop: explain };
|
||
}
|
||
|
||
export function buildMethodFollowupPlan(input: {
|
||
evidence: readonly MethodFollowupEvidence[];
|
||
activeFocus?: MethodFollowupFocus | null;
|
||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||
observations?: readonly InternalVargaObservation[];
|
||
sessionOutcome?: SessionOutcomeKind;
|
||
precisionStage?: PrecisionStageId | null;
|
||
nakshatraBoundary?: NakshatraBoundary | null;
|
||
oosBlindPrompts?: readonly OosBlindPrompt[];
|
||
eventProbes?: readonly DiscriminatingEventProbe[];
|
||
askedProbeKeys?: readonly string[];
|
||
birthDate?: string | null;
|
||
accepted?: boolean;
|
||
}): 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);
|
||
return {
|
||
...base,
|
||
choice_frame: attach
|
||
? buildChoiceFrame(base, {
|
||
observations: input.observations,
|
||
evidence: input.evidence,
|
||
probes: input.eventProbes,
|
||
birthDate: input.birthDate,
|
||
scoring,
|
||
})
|
||
: null,
|
||
};
|
||
};
|
||
const declined = declinedDomains(input.declinedTopics ?? []);
|
||
const dashaCovered = input.evidence.some(isConfirmedDated);
|
||
const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship");
|
||
const careerCovered = hasConfirmedDomain(input.evidence, "career");
|
||
const familyCovered = hasConfirmedDomain(input.evidence, "family");
|
||
const financeCovered = hasConfirmedDomain(input.evidence, "finance");
|
||
const healthCovered = hasConfirmedHealth(input.evidence);
|
||
const occupationCovered = hasConfirmedDomain(input.evidence, "occupation")
|
||
|| input.evidence.some(isOccupationNote)
|
||
|| declined.has("occupation");
|
||
const horaryGiven = hasConfirmedDomain(input.evidence, "horary");
|
||
const horaryStatus: MethodCoverageStatus = horaryGiven
|
||
? "covered"
|
||
: declined.has("horary")
|
||
? "skipped_by_policy"
|
||
: "uncovered";
|
||
|
||
const methods: MethodCoverage[] = [
|
||
coverage("dasha_events", dashaCovered ? "covered" : "uncovered"),
|
||
coverage("d9_relationship", relationshipCovered || declined.has("relationship") ? "covered" : "uncovered"),
|
||
coverage("d10_career", careerCovered || declined.has("career") ? "covered" : "uncovered"),
|
||
coverage("relatives", familyCovered || declined.has("family") ? "covered" : "uncovered"),
|
||
coverage("appearance", "skipped_by_policy"),
|
||
coverage("marks", "skipped_by_policy"),
|
||
coverage("occupation", occupationCovered ? "covered" : "uncovered"),
|
||
coverage("horary", horaryStatus),
|
||
];
|
||
|
||
const sessionOutcome = input.sessionOutcome ?? "collect_evidence";
|
||
const focus = input.activeFocus ?? null;
|
||
const keepAcceptedFocus = Boolean(
|
||
focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"),
|
||
);
|
||
const coverageComplete = blockingMethodsCovered(methods);
|
||
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")
|
||
),
|
||
);
|
||
if (
|
||
focus
|
||
&& !staleCollectFocus
|
||
&& (sessionOutcome !== "adopt_representative" || 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"
|
||
));
|
||
return {
|
||
methods,
|
||
next_followup: makeFollowup({
|
||
method_id: "active_focus",
|
||
intent: focus.intent || "active_focus",
|
||
ask_theme: "active_focus",
|
||
domain: focus.targetDomain,
|
||
kind_hint: focus.targetKind,
|
||
user_prompt_hint: keepChoice
|
||
? "先承接当前服务器焦点。根据 choice_frame 自己写题干和 A/B/C/D,经 set-focus.expectedAnswerSchema.choice 交给点选卡;年份不得发明,不要照抄 hint。正文不要复述选项。"
|
||
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则继续用自然语言问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
|
||
source: "active_focus",
|
||
}, true, keepChoice),
|
||
deferred_followup: null,
|
||
session_outcome: sessionOutcome,
|
||
stop_domain_rotation: true,
|
||
do_not_poll: DO_NOT_POLL,
|
||
not_in_rotation: NOT_IN_ROTATION,
|
||
};
|
||
}
|
||
|
||
if (input.accepted) {
|
||
const probe = remainingReverseVerifyProbes(input.eventProbes, input.evidence, declined)[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: agentHint(
|
||
`当前排盘已采用。按该分钟核对:${probe.year_label} 是否有${probe.event_family}。对得上写入账本并重算;对不上可以改选其他候选。不确认唯一分钟。`,
|
||
REVERSE_VERIFY_VARGA[probe.domain],
|
||
),
|
||
source: "reverse_verify",
|
||
}, true, true)
|
||
: null;
|
||
return {
|
||
methods,
|
||
next_followup: next,
|
||
deferred_followup: null,
|
||
session_outcome: sessionOutcome,
|
||
stop_domain_rotation: true,
|
||
do_not_poll: DO_NOT_POLL,
|
||
not_in_rotation: NOT_IN_ROTATION,
|
||
};
|
||
}
|
||
|
||
let next: MethodFollowup | null = null;
|
||
const stage = input.precisionStage ?? null;
|
||
const askedKeys = new Set(input.askedProbeKeys ?? []);
|
||
const conflictProbe = dashaCovered
|
||
? remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys)[0] ?? null
|
||
: null;
|
||
if (!dashaCovered) {
|
||
next = makeFollowup({
|
||
method_id: "dasha_events",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "dated_event",
|
||
domain: null,
|
||
kind_hint: null,
|
||
user_prompt_hint: collectHint(
|
||
"可以先从最容易想起的一件带大概时间的经历开始。",
|
||
"本命 Dasha + 行运(方法1)",
|
||
"不要求一次列出 10–15 条。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (conflictProbe && (!coverageComplete || (conflictProbe.information_gain ?? 0) >= 0.08)) {
|
||
next = 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: agentHint(
|
||
`当前候选时间还分不开。按冲突分钟反推:${conflictProbe.year_label} 是否有${conflictProbe.event_family}。对得上写入账本并重算以筛窗;对不上关闭该问。不要问两套盘哪个更像。不确认唯一分钟。`,
|
||
REVERSE_VERIFY_VARGA[conflictProbe.domain],
|
||
),
|
||
source: "event_probe",
|
||
information_gain: conflictProbe.information_gain ?? 0,
|
||
semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`,
|
||
candidate_split_hash: conflictProbe.candidate_split_hash,
|
||
probe_year: conflictProbe.year,
|
||
}, true, true);
|
||
} else if (!relationshipCovered && !declined.has("relationship")) {
|
||
next = makeFollowup({
|
||
method_id: "d9_relationship",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "relationship_style",
|
||
domain: "relationship",
|
||
kind_hint: "relationship_start",
|
||
user_prompt_hint: collectHint(
|
||
"可以先说一段记得大概时间的感情或关系变化。",
|
||
"D9",
|
||
"对照 D9 上升类型表(白羊主动热情、天蝎深刻占有等)只作校时方法,不是命运承诺。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (!careerCovered && !declined.has("career")) {
|
||
next = makeFollowup({
|
||
method_id: "d10_career",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "career_style",
|
||
domain: "career",
|
||
kind_hint: "career_entry",
|
||
user_prompt_hint: collectHint(
|
||
"可以先说一段记得大概时间的工作或事业变化。同一件事会同时对照本命第 10 宫和 D10。",
|
||
"D10",
|
||
"可用 D10 事业类型表作校时对照。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (!familyCovered && !declined.has("family")) {
|
||
next = makeFollowup({
|
||
method_id: "relatives",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "family_event",
|
||
domain: "family",
|
||
kind_hint: "family_event",
|
||
user_prompt_hint: collectHint(
|
||
"可以先说一段记得大概时间的家人相关变化。同一件事会对照 D12 父母盘、D7 子女盘和 D3 兄弟盘。",
|
||
"D12 / D7 / D3",
|
||
"六亲用 Raman 六步:前三步可执行,后三步标方法论层。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (!occupationCovered) {
|
||
next = makeFollowup({
|
||
method_id: "occupation",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "occupation",
|
||
domain: "occupation",
|
||
kind_hint: "occupation_note",
|
||
user_prompt_hint: collectHint(
|
||
"你长期做什么工作?对照本命第 10 宫和 D10。",
|
||
"D1-H10 + D10",
|
||
"可用事业类型表(白羊领导创业、天蝎研究转化等)作校时方法。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (horaryStatus === "uncovered") {
|
||
next = makeFollowup({
|
||
method_id: "horary",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "horary",
|
||
domain: "horary",
|
||
kind_hint: "horary_query",
|
||
user_prompt_hint: collectHint(
|
||
"有没有第一次认真问起这件事的时间?",
|
||
"占问观察盘",
|
||
"有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。",
|
||
),
|
||
source: "method_coverage",
|
||
});
|
||
} else if (stage === "lagna_frame") {
|
||
next = makeFollowup({
|
||
method_id: "dasha_events",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "dated_event",
|
||
domain: null,
|
||
kind_hint: null,
|
||
user_prompt_hint: agentHint(
|
||
"窗口里本命上升还可能落在两段。按 choice_frame.period 与探针问一件前事是否发生,用来筛这两段。不要问两套盘哪个更像。",
|
||
"本命上升 / Dasha",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if (stage === "d9_refine" && !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: agentHint(
|
||
"关系盘仍会换升。按探针年份问感情这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D9",
|
||
"对照 D9 上升类型表只作校时方法。",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if (stage === "d10_refine" && !declined.has("career")) {
|
||
next = makeFollowup({
|
||
method_id: "d10_career",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "career_style",
|
||
domain: "career",
|
||
kind_hint: "career_change",
|
||
user_prompt_hint: agentHint(
|
||
"事业盘仍会换升。按探针年份问事业这条线的前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D10",
|
||
"可用 D10 事业类型表作校时对照。",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if ((stage === "d4_refine" || stage === "theme_refine") && !declined.has("relocation")) {
|
||
next = makeFollowup({
|
||
method_id: "d4_home",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "home_change",
|
||
domain: "relocation",
|
||
kind_hint: "home_change",
|
||
user_prompt_hint: agentHint(
|
||
"居所盘仍会换升。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D4",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else if (stage === "d5_refine" && !declined.has("education")) {
|
||
next = makeFollowup({
|
||
method_id: "d5_education",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "education_style",
|
||
domain: "education",
|
||
kind_hint: "education_milestone",
|
||
user_prompt_hint: agentHint(
|
||
"成就盘或学业盘仍会换升。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"D5 / D24",
|
||
),
|
||
source: "precision_stage",
|
||
});
|
||
} else {
|
||
const d9 = input.observations?.find((item) => item.layer === "d9");
|
||
const d10 = input.observations?.find((item) => item.layer === "d10");
|
||
const d4 = input.observations?.find((item) => item.layer === "d4");
|
||
const d5 = input.observations?.find((item) => item.layer === "d5");
|
||
const d7 = input.observations?.find((item) => item.layer === "d7");
|
||
const d12 = input.observations?.find((item) => item.layer === "d12");
|
||
const d11 = input.observations?.find((item) => item.layer === "d11");
|
||
const d30 = input.observations?.find((item) => item.layer === "d30");
|
||
if (d9?.candidates_differ && !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: agentHint(
|
||
"当前候选在关系主题上仍分不开。按探针年份问感情前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"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: agentHint(
|
||
"当前候选在事业主题上仍分不开。按探针年份问事业前事是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"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: agentHint(
|
||
"当前候选在居所主题上仍分不开。按探针年份问搬家或住处变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"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: agentHint(
|
||
"当前候选在学业或成就主题上仍分不开。按探针年份问学业或考试变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"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: agentHint(
|
||
"当前候选在家人主题上仍分不开。按探针年份问家人变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"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: agentHint(
|
||
"当前候选在财务主题上仍分不开。按探针年份问财务变化是否发生,用来筛窗。不要问两套盘哪个更像。",
|
||
"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: agentHint(
|
||
"当前候选在健康压力主题上仍分不开。按探针年份问健康或压力变化是否发生,用来筛窗。这不是医学判断。不要问两套盘哪个更像。",
|
||
"D30",
|
||
),
|
||
source: "varga_observation",
|
||
});
|
||
} else if (input.nakshatraBoundary?.near_boundary) {
|
||
next = makeFollowup({
|
||
method_id: "nakshatra_boundary",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "nakshatra_trait",
|
||
domain: null,
|
||
kind_hint: null,
|
||
user_prompt_hint: input.nakshatraBoundary.user_meaning
|
||
?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。",
|
||
source: "nakshatra_boundary",
|
||
});
|
||
}
|
||
}
|
||
|
||
const deferAdoption = sessionOutcome === "adopt_representative";
|
||
return {
|
||
methods,
|
||
next_followup: deferAdoption ? null : next,
|
||
deferred_followup: deferAdoption ? next : null,
|
||
session_outcome: sessionOutcome,
|
||
stop_domain_rotation: true,
|
||
do_not_poll: DO_NOT_POLL,
|
||
not_in_rotation: NOT_IN_ROTATION,
|
||
};
|
||
}
|
||
|
||
export function projectRectificationChoiceCard(
|
||
input: Parameters<typeof buildMethodFollowupPlan>[0] & {
|
||
selectionAllowed?: boolean;
|
||
proposeAllowed?: boolean;
|
||
userStopped?: boolean;
|
||
},
|
||
): RectificationChoiceCard | null {
|
||
const plan = buildMethodFollowupPlan(input);
|
||
const sessionOutcome = conversationalSessionOutcome({
|
||
selectionAllowed: input.selectionAllowed === true,
|
||
proposeAllowed: input.proposeAllowed === true,
|
||
confirmationAllowed: false,
|
||
nextFollowup: plan.next_followup,
|
||
methods: plan.methods,
|
||
userStopped: input.userStopped,
|
||
});
|
||
if (
|
||
!input.accepted
|
||
&& (sessionOutcome === "adopt_representative" || sessionOutcome === "awaiting_confirmation")
|
||
) {
|
||
return null;
|
||
}
|
||
const frame = plan.next_followup?.choice_frame ?? plan.deferred_followup?.choice_frame ?? null;
|
||
if (!frame) return null;
|
||
return mergeChoiceCard(frame, parseAgentChoiceCopy(input.activeFocus?.expectedAnswerSchema ?? null));
|
||
}
|