Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/method-followup.ts
T
Jesse_ChenandCursor b1b4f5fac9
Independent Staging Quality Gate / validate (push) Successful in 10m19s
Independent Staging Quality Gate / publish (push) Successful in 8m7s
fix(rectification): score family events and allow appearance follow-up
Dated family evidence now moves candidates via D12 and kin houses, career receipts expose both D1-10 and D10, and appearance/marks may be asked as auxiliary first-house scores. New cases bind Skill 10.0.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 08:35:46 +08:00

458 lines
17 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:
* 1. Dasha + dated events — any confirmed dated event
* 2. D9 relationship — confirmed relationship evidence; no sign labels
* 3. D10 career — confirmed career evidence; same event also scores D1 10th house
* 4. Relatives — confirmed family evidence (D12 + 六亲 houses)
* 5. Appearance / constitution — ask; dated answers are auxiliary 1st-house scores
* 6. Birthmarks / scars — ask; dated answers are auxiliary 1st-house scores
* 7. Occupation / 10th house — folded into method 3 (D10 + D1 10th)
* 8. Horary — unsupported; skip
*/
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
import type {
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",
"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" | "relatives" | "appearance" | "marks" | "active_focus" | "nakshatra_boundary" | "oos_blind";
intent: string;
ask_theme: "dated_event" | "relationship_style" | "career_style" | "family_event" | "appearance" | "marks" | "active_focus" | "nakshatra_trait" | "oos_blind";
domain: string | null;
kind_hint: string | null;
user_prompt_hint: string;
must_not_label: true;
source: "active_focus" | "method_coverage" | "varga_observation" | "precision_stage" | "nakshatra_boundary" | "oos_blind";
}>;
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 ["horary"];
not_in_rotation: readonly ["relocation", "finance", "health"];
}>;
export type MethodFollowupEvidence = Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
}>;
export type MethodFollowupFocus = Readonly<{
intent: string;
targetDomain: string | null;
targetKind: string | null;
}>;
const DO_NOT_POLL = ["horary"] as const;
const NOT_IN_ROTATION = ["relocation", "finance", "health"] as const;
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 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;
}
function coverage(
methodId: MethodFollowupId,
status: MethodCoverageStatus,
): MethodCoverage {
return { method_id: methodId, status };
}
function followup(
input: Omit<MethodFollowup, "must_not_label">,
): MethodFollowup {
return { ...input, must_not_label: true };
}
export type NextUserActionId =
| "adopt_representative"
| "score_now"
| "record_stated_events"
| "ask_method_followup"
| "explain_current_window"
| "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 };
}
export function conversationalSessionOutcome(input: {
selectionAllowed: boolean;
confirmationAllowed: boolean;
nextFollowup: MethodFollowup | null;
}): SessionOutcomeKind {
if (input.confirmationAllowed) return "awaiting_confirmation";
if (input.selectionAllowed && !input.nextFollowup) return "adopt_representative";
return "collect_evidence";
}
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",
"代表性时间已写入当前排盘。请用户用这个时间看盘;不要声称已确认唯一分钟。盘外对照问句由界面展示,本轮不要再当必须补证据。",
);
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"
|| (input.selectionAllowed && !input.nextFollowup)
) {
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[];
accepted?: boolean;
}): MethodFollowupPlan {
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 appearanceCovered = hasConfirmedDomain(input.evidence, "appearance");
const marksCovered = hasConfirmedDomain(input.evidence, "marks");
const methods: MethodCoverage[] = [
coverage("dasha_events", dashaCovered ? "covered" : "uncovered"),
coverage("d9_relationship", relationshipCovered ? "covered" : "uncovered"),
coverage("d10_career", careerCovered ? "covered" : "uncovered"),
coverage("relatives", familyCovered ? "covered" : "uncovered"),
coverage("appearance", appearanceCovered ? "covered" : "uncovered"),
coverage("marks", marksCovered ? "covered" : "uncovered"),
coverage("horary", "skipped_by_policy"),
];
const sessionOutcome = input.sessionOutcome ?? "collect_evidence";
const focus = input.activeFocus ?? null;
if (focus) {
return {
methods,
next_followup: followup({
method_id: "active_focus",
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: "先承接当前服务器焦点,不要另开领域清单。",
source: "active_focus",
}),
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 oos = oosFollowup(input.oosBlindPrompts);
return {
methods,
next_followup: null,
deferred_followup: oos,
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;
if (!dashaCovered) {
next = followup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: "可以先从最容易想起的一件带大概时间的经历开始。",
source: "method_coverage",
});
} else if (stage === "lagna_frame") {
next = followup({
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: "窗口里本命上升还可能落在两段。请再补一件记得大概时间的经历,用来分开这两段;不要用性格或类型标签来选。",
source: "precision_stage",
});
} else if (stage === "d9_refine" && !declined.has("relationship")) {
next = followup({
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "关系盘仍会换升。可以再补一件记得大概时间的感情或关系变化;不要描述星座或类型标签。",
source: "precision_stage",
});
} else if (stage === "d10_refine" && !declined.has("career")) {
next = followup({
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: "career_change",
user_prompt_hint: "事业盘仍会换升。可以再补一件记得大概时间的工作变化;同一件事会同时对照本命第 10 宫和 D10,不要描述类型标签。",
source: "precision_stage",
});
} else if (stage === "theme_refine" && !declined.has("family")) {
next = followup({
method_id: "relatives",
intent: "distinguish_candidates",
ask_theme: "family_event",
domain: "family",
kind_hint: "family_event",
user_prompt_hint: "若还想继续收窄,可以再补一件记得大概时间的家人变化;也可以先采用代表性时间。",
source: "precision_stage",
});
} else if (!relationshipCovered && !declined.has("relationship")) {
next = followup({
method_id: "d9_relationship",
intent: "collect_method_evidence",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_start",
user_prompt_hint: "可以先说一段记得大概时间的感情或关系变化,不必描述对象星座或类型标签。",
source: "method_coverage",
});
} else if (!careerCovered && !declined.has("career")) {
next = followup({
method_id: "d10_career",
intent: "collect_method_evidence",
ask_theme: "career_style",
domain: "career",
kind_hint: "career_entry",
user_prompt_hint: "可以先说一段记得大概时间的工作或事业变化。同一件事会同时对照本命第 10 宫和 D10。",
source: "method_coverage",
});
} else if (!familyCovered && !declined.has("family")) {
next = followup({
method_id: "relatives",
intent: "collect_method_evidence",
ask_theme: "family_event",
domain: "family",
kind_hint: "family_event",
user_prompt_hint: "可以先说一段记得大概时间的家人相关变化。",
source: "method_coverage",
});
} else if (!appearanceCovered && !declined.has("appearance")) {
next = followup({
method_id: "appearance",
intent: "collect_method_evidence",
ask_theme: "appearance",
domain: "appearance",
kind_hint: "appearance_note",
user_prompt_hint: "外貌或体质有没有比较稳定的特点?如果记得某次明显变化的大概时间,也可以说。这只作辅助对照,不会当成主评分。",
source: "method_coverage",
});
} else if (!marksCovered && !declined.has("marks")) {
next = followup({
method_id: "marks",
intent: "collect_method_evidence",
ask_theme: "marks",
domain: "marks",
kind_hint: "birthmark_or_scar",
user_prompt_hint: "有没有胎记,或记得大概时间的疤痕、受伤?有日期的会进一宫辅助对照,不会当成主评分。",
source: "method_coverage",
});
} else {
const d9 = input.observations?.find((item) => item.layer === "d9");
const d10 = input.observations?.find((item) => item.layer === "d10");
const d12 = input.observations?.find((item) => item.layer === "d12");
if (d9?.candidates_differ && !declined.has("relationship")) {
next = followup({
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "当前候选在关系主题上仍分不开,可以再补一件记得大概时间的感情或关系变化;不要描述星座或类型标签。",
source: "varga_observation",
});
} else if (d10?.candidates_differ && !declined.has("career")) {
next = followup({
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: "career_change",
user_prompt_hint: "当前候选在事业主题上仍分不开,可以再补一件记得大概时间的工作变化;不要描述类型标签。同一件事会同时对照本命第 10 宫和 D10。",
source: "varga_observation",
});
} else if (d12?.candidates_differ && !declined.has("family")) {
next = followup({
method_id: "relatives",
intent: "distinguish_candidates",
ask_theme: "family_event",
domain: "family",
kind_hint: "family_event",
user_prompt_hint: "当前候选在家人主题上仍分不开,可以再补一件记得大概时间的家人变化。",
source: "varga_observation",
});
} else if (input.nakshatraBoundary?.near_boundary) {
next = followup({
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,
};
}
function oosFollowup(prompts: readonly OosBlindPrompt[] | undefined): MethodFollowup | null {
const prompt = prompts?.[0];
if (!prompt) return null;
return followup({
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "oos_blind",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
});
}