Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/method-followup.ts
T
Jesse_ChenandCursor 3b785b821d
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(web): unblock staging web image typecheck for inference probes
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>
2026-08-24 09:40:48 +08:00

914 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Eight-method follow-up routing for birth-time rectification.
*
* Web used to round-robin SQL missing_evidence_categories (relocation /
* health / finance). Local skill asks by method layer. This plan is the
* server's next question. It still only produces candidates, never a
* confirmed unique minute.
*
* Policy map (classic eight):
* 1. Dasha + dated events — any confirmed dated event
* 2. D9 relationship — confirmed relationship evidence; D9 type table is a
* rectification method, not a fate promise
* 3. D10 career — confirmed dated career evidence; same event also scores D1 10th house
* 4. Relatives — confirmed family evidence (D12 + D7 + D3)
* 5. Appearance / constitution — skipped; never asked
* 6. Birthmarks / scars — skipped; never asked
* 7. Occupation / 10th house — separate from dated career events; D10 type table allowed.
* A draft/confirmed occupation_note without a date still covers this layer.
* 8. Horary — ask once for the first question time; recast if given; never blocks cards
*
* Relocation stays out of domain rotation and is only asked at d4_refine.
* Finance/health score if volunteered; they are not method-layer rotation.
* Method coverage finishes before repeating a precision-stage ask.
* Appearance and marks are skipped_by_policy. Horary does not block offering
* time cards. Occupation does block cards until a note exists.
* Method coverage asks for dated events in natural language.
* 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));
}