Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/method-followup.ts
T
Jesse_ChenandCursor 233c728176
Independent Staging Quality Gate / validate (push) Successful in 9m12s
Independent Staging Quality Gate / publish (push) Successful in 7m28s
fix(rectification): route follow-ups by method layer and rescore when evidence changes
Web was round-robinning missing domains and waiting to score until the user said they had no more events. Server follow-up now uses the eight-method plan, rescored snapshots stay candidates, and D9/D10 observations never become user labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 09:56:54 +08:00

231 lines
7.8 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 (occupation folded in)
* 4. Relatives — confirmed family evidence
* 5. Appearance / constitution — never poll
* 6. Birthmarks / scars — never poll
* 7. Occupation / 10th house — folded into method 3
* 8. Horary — unsupported; skip
*/
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" | "active_focus";
intent: string;
ask_theme: "dated_event" | "relationship_style" | "career_style" | "family_event" | "active_focus";
domain: string | null;
kind_hint: string | null;
user_prompt_hint: string;
must_not_label: true;
source: "active_focus" | "method_coverage" | "varga_observation";
}>;
export type MethodFollowupPlan = Readonly<{
methods: readonly MethodCoverage[];
next_followup: MethodFollowup | null;
stop_domain_rotation: true;
do_not_poll: readonly ["appearance", "marks", "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 = ["appearance", "marks", "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 function buildMethodFollowupPlan(input: {
evidence: readonly MethodFollowupEvidence[];
activeFocus?: MethodFollowupFocus | null;
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
observations?: readonly InternalVargaObservation[];
}): 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 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", "skipped_by_policy"),
coverage("marks", "skipped_by_policy"),
coverage("horary", "skipped_by_policy"),
];
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",
}),
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
};
}
let next: MethodFollowup | null = 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 (!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: "可以先说一段记得大概时间的工作或事业变化。",
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 {
const d9 = input.observations?.find((item) => item.layer === "d9");
const d10 = input.observations?.find((item) => item.layer === "d10");
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: "当前候选在事业主题上仍分不开,可以再补一件记得大概时间的工作变化;不要描述类型标签。",
source: "varga_observation",
});
}
}
return {
methods,
next_followup: next,
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
};
}