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>
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import {
|
||||
internalObservationsFromWindowScan,
|
||||
parseWindowScan,
|
||||
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
import {
|
||||
resolveExactSkillPackage,
|
||||
} from "../src/lib/skill-package-registry.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
CANDIDATE_ID,
|
||||
EVIDENCE_ID,
|
||||
RESULT_ID,
|
||||
SECOND_CANDIDATE_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
candidateSnapshotFixture,
|
||||
computeFixture,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
|
||||
const EDUCATION_ID = "44444444-4444-4444-8444-444444444441";
|
||||
const FORBIDDEN_LABELS = /白羊|金牛|双子|巨蟹|狮子|处女|天秤|天蝎|射手|摩羯|水瓶|双鱼|热情冲动|配偶类型|事业特质/;
|
||||
|
||||
const ENGINE_SCORE = {
|
||||
success: true,
|
||||
endpoint: "rectification_v5_score",
|
||||
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
|
||||
algorithm_version: "rectification-event-contract-v2",
|
||||
event_contract_version: "rectification-event-contract-v2",
|
||||
decision_policy_version: "rectification-candidate-policy-v2",
|
||||
execution_ledger_version: "rectification-execution-ledger-v2",
|
||||
candidate_decisions: [
|
||||
{ candidate_id: CANDIDATE_ID, time: "04:50", rank: 1, relative_support: 57, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, time: "04:51", rank: 2, relative_support: 25, tied_minute_count: 2 },
|
||||
{ candidate_id: THIRD_CANDIDATE_ID, time: "04:52", rank: 3, relative_support: 18, tied_minute_count: 2 },
|
||||
],
|
||||
decision_receipt: {
|
||||
receipt_version: "candidate-decision-receipt-v2",
|
||||
contract_version: "v2",
|
||||
event_contract_version: "rectification-event-contract-v2",
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
decision_policy_version: "rectification-candidate-policy-v2",
|
||||
display_allowed: true,
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
accept_allowed: true,
|
||||
confirm_allowed: false,
|
||||
representative_candidate_id: CANDIDATE_ID,
|
||||
representative_time: "04:50",
|
||||
overall_confidence: "high",
|
||||
margin_percent: 42.5,
|
||||
},
|
||||
execution_ledger: [
|
||||
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" },
|
||||
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d9-navamsa", status: "executed", source: "python-engine" },
|
||||
],
|
||||
diagnostics: {
|
||||
window_scan: {
|
||||
scanned: true,
|
||||
confirmation_allowed: false,
|
||||
unique_minute_claim: false,
|
||||
d9_lagna_count: 2,
|
||||
d10_lagna_count: 1,
|
||||
d9_candidates_differ: true,
|
||||
d10_candidates_differ: false,
|
||||
d9_sign_names: ["白羊座", "天蝎"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const educationEvidence = {
|
||||
id: EDUCATION_ID,
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "education_milestone",
|
||||
domain: "education",
|
||||
occurred_from: "2016-06-01",
|
||||
occurred_to: null,
|
||||
date_precision: "month",
|
||||
summary: "2016年6月一次学业节点",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:06.000Z",
|
||||
};
|
||||
|
||||
function stubEngine(response: unknown) {
|
||||
const previous = globalThis.fetch;
|
||||
globalThis.fetch = (async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => response,
|
||||
})) as unknown as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = previous;
|
||||
};
|
||||
}
|
||||
|
||||
test("eight-method routing asks relationship after dated education, not relocation", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-06-01",
|
||||
occurredTo: null,
|
||||
}],
|
||||
});
|
||||
assert.equal(plan.next_followup?.method_id, "d9_relationship");
|
||||
assert.equal(plan.next_followup?.domain, "relationship");
|
||||
assert.equal(plan.stop_domain_rotation, true);
|
||||
assert.deepEqual([...plan.not_in_rotation], ["relocation", "finance", "health"]);
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "appearance")?.status, "skipped_by_policy");
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy");
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "skipped_by_policy");
|
||||
assert.notEqual(plan.next_followup?.method_id, "appearance");
|
||||
assert.doesNotMatch(JSON.stringify(plan), FORBIDDEN_LABELS);
|
||||
});
|
||||
|
||||
test("declined relationship skips to career and never polls appearance", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2016-01-01",
|
||||
occurredTo: null,
|
||||
}],
|
||||
declinedTopics: [{ target_domain: "relationship", status: "declined" }],
|
||||
});
|
||||
assert.equal(plan.next_followup?.method_id, "d10_career");
|
||||
assert.equal(plan.next_followup?.domain, "career");
|
||||
assert.equal(plan.do_not_poll.includes("appearance"), true);
|
||||
});
|
||||
|
||||
test("D9 differ becomes an internal ask theme without sign labels", () => {
|
||||
const scan = parseWindowScan({
|
||||
scanned: true,
|
||||
confirmation_allowed: true,
|
||||
unique_minute_claim: true,
|
||||
d9_lagna_count: 2,
|
||||
d10_lagna_count: 1,
|
||||
d9_candidates_differ: true,
|
||||
d10_candidates_differ: false,
|
||||
d9_sign_names: ["白羊座", "天蝎"],
|
||||
type_table: "热情冲动",
|
||||
});
|
||||
assert.ok(scan);
|
||||
assert.equal(scan.confirmation_allowed, false);
|
||||
assert.equal(scan.unique_minute_claim, false);
|
||||
assert.equal(scan.d9_candidates_differ, true);
|
||||
const observations = internalObservationsFromWindowScan(scan);
|
||||
assert.deepEqual(observations, [
|
||||
{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" },
|
||||
{ layer: "d10", candidates_differ: false, ask_theme: null },
|
||||
]);
|
||||
assert.doesNotMatch(JSON.stringify({ scan, observations }), FORBIDDEN_LABELS);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [
|
||||
{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null },
|
||||
{ status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2018-01-01", occurredTo: null },
|
||||
{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2019-01-01", occurredTo: null },
|
||||
{ status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null },
|
||||
],
|
||||
observations,
|
||||
});
|
||||
assert.equal(plan.next_followup?.source, "varga_observation");
|
||||
assert.equal(plan.next_followup?.ask_theme, "relationship_style");
|
||||
assert.doesNotMatch(JSON.stringify(plan), FORBIDDEN_LABELS);
|
||||
});
|
||||
|
||||
test("read-case follows method plan and hides D9/D10 labels even when SQL missing categories rotate", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [educationEvidence],
|
||||
conversationSummary: {
|
||||
confirmed_evidence_summary: [],
|
||||
pending_revisions: [],
|
||||
active_focus: null,
|
||||
declined_skipped_topics: [],
|
||||
candidate_divergence_summary: null,
|
||||
missing_evidence_categories: ["relocation", "health", "finance"],
|
||||
last_result_policy: null,
|
||||
summary_version: 1,
|
||||
updated_at: "2026-08-12T10:00:06.000Z",
|
||||
},
|
||||
latestResult: {
|
||||
...candidateSnapshotFixture({
|
||||
confirmationAllowed: true,
|
||||
representativeTime: "04:45",
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:45", relative_support: 40, tied_minute_count: 25 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:46", relative_support: 35, tied_minute_count: 25 },
|
||||
{ candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "04:47", relative_support: 25, tied_minute_count: 25 },
|
||||
],
|
||||
}),
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: true,
|
||||
decision_receipt: {
|
||||
receipt_version: "candidate-decision-receipt-v2",
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
representative_candidate_id: CANDIDATE_ID,
|
||||
overall_confidence: "medium",
|
||||
window_scan: {
|
||||
scanned: true,
|
||||
confirmation_allowed: false,
|
||||
unique_minute_claim: false,
|
||||
d9_lagna_count: 2,
|
||||
d10_lagna_count: 1,
|
||||
d9_candidates_differ: true,
|
||||
d10_candidates_differ: false,
|
||||
d9_sign_names: ["白羊", "天蝎"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
const projection = await (tools["rectification-read-case"] as unknown as {
|
||||
execute(input: unknown): Promise<{
|
||||
conversation_summary: { missing_evidence_categories: string[] };
|
||||
method_followup_plan: { next_followup: { method_id: string; domain: string | null } | null };
|
||||
internal_observations: Array<{ layer: string; ask_theme: string | null }>;
|
||||
latest_result: { confirmation_allowed: boolean; indistinguishable_width_minutes: number; window_scan: { d9_candidates_differ: boolean } | null };
|
||||
}>;
|
||||
}).execute({ caseId: CASE_ID });
|
||||
assert.deepEqual(projection.conversation_summary.missing_evidence_categories, ["relocation", "health", "finance"]);
|
||||
assert.equal(projection.method_followup_plan.next_followup?.method_id, "d9_relationship");
|
||||
assert.equal(projection.method_followup_plan.next_followup?.domain, "relationship");
|
||||
assert.equal(projection.internal_observations.find((item) => item.layer === "d9")?.ask_theme, "relationship_style");
|
||||
assert.equal(projection.latest_result.confirmation_allowed, false);
|
||||
assert.ok(projection.latest_result.indistinguishable_width_minutes >= 25);
|
||||
assert.equal(projection.latest_result.window_scan?.d9_candidates_differ, true);
|
||||
assert.doesNotMatch(JSON.stringify(projection), FORBIDDEN_LABELS);
|
||||
});
|
||||
|
||||
test("accepted batch evidence triggers server rescore without offering adoption", async () => {
|
||||
const restore = stubEngine(ENGINE_SCORE);
|
||||
try {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [educationEvidence],
|
||||
latestResult: null,
|
||||
}),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
record_agentic_rectification_evidence_batch: () => ({
|
||||
items: [{
|
||||
index: 0,
|
||||
outcome: "accepted",
|
||||
evidence_id: EDUCATION_ID,
|
||||
status: "confirmed",
|
||||
idempotent: false,
|
||||
clarification_fields: [],
|
||||
error_code: null,
|
||||
}],
|
||||
accepted_count: 1,
|
||||
needs_clarification_count: 0,
|
||||
rejected_count: 0,
|
||||
focus_id: null,
|
||||
}),
|
||||
persist_agentic_rectification_candidate_v2: () => ({
|
||||
...candidateSnapshotFixture(),
|
||||
cached: false,
|
||||
}),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
const result = await (tools["rectification-record-evidence-batch"] as unknown as {
|
||||
execute(input: unknown): Promise<{
|
||||
accepted_count: number;
|
||||
rescore: { status: string; executed_methods: string[]; error_code: string | null };
|
||||
}>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
items: [{
|
||||
quote: "2016年6月高考结束",
|
||||
proposedKind: "education_milestone",
|
||||
subject: "self",
|
||||
domain: "education",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-06",
|
||||
summary: "2016年6月一次学业节点",
|
||||
}],
|
||||
});
|
||||
assert.equal(result.accepted_count, 1);
|
||||
assert.equal(result.rescore.status, "completed");
|
||||
assert.ok(result.rescore.executed_methods.includes("d1-rashi"));
|
||||
const persistCall = accounting.calls.find((call) => call.fn === "persist_agentic_rectification_candidate_v2");
|
||||
assert.ok(persistCall);
|
||||
const receipt = persistCall.args.p_decision_receipt as { window_scan?: { d9_candidates_differ?: boolean; d9_sign_names?: unknown } };
|
||||
assert.equal(receipt.window_scan?.d9_candidates_differ, true);
|
||||
assert.equal("d9_sign_names" in (receipt.window_scan ?? {}), false);
|
||||
const completedReceipt = accounting.calls.find((call) =>
|
||||
call.fn === "insert_agentic_rectification_tool_receipt"
|
||||
&& call.args.p_tool_name === "rectification-record-evidence-batch"
|
||||
&& call.args.p_status === "completed"
|
||||
);
|
||||
assert.ok(completedReceipt);
|
||||
assert.ok((completedReceipt.args.p_executed_methods as string[]).includes("d1-rashi"));
|
||||
assert.equal(
|
||||
accounting.calls.some((call) =>
|
||||
call.fn === "transition_agentic_rectification_case_status"
|
||||
&& call.args.p_to_status === "candidate_ready"
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(result), FORBIDDEN_LABELS);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rescore failure does not fail the evidence write", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [educationEvidence],
|
||||
}),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
record_agentic_rectification_evidence_batch: () => ({
|
||||
items: [{
|
||||
index: 0,
|
||||
outcome: "accepted",
|
||||
evidence_id: EDUCATION_ID,
|
||||
status: "confirmed",
|
||||
idempotent: false,
|
||||
clarification_fields: [],
|
||||
error_code: null,
|
||||
}],
|
||||
accepted_count: 1,
|
||||
needs_clarification_count: 0,
|
||||
rejected_count: 0,
|
||||
focus_id: null,
|
||||
}),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
const result = await (tools["rectification-record-evidence-batch"] as unknown as {
|
||||
execute(input: unknown): Promise<{
|
||||
accepted_count: number;
|
||||
rescore: { status: string; error_code: string | null };
|
||||
}>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
items: [{
|
||||
quote: "2016年6月高考结束",
|
||||
proposedKind: "education_milestone",
|
||||
subject: "self",
|
||||
domain: "education",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-06",
|
||||
summary: "2016年6月一次学业节点",
|
||||
}],
|
||||
});
|
||||
assert.equal(result.accepted_count, 1);
|
||||
assert.equal(result.rescore.status, "failed");
|
||||
assert.ok(result.rescore.error_code);
|
||||
});
|
||||
|
||||
test("public tool surface stays at 13 and new cases bind 10.0.2", () => {
|
||||
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 13);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.2");
|
||||
const deprecated = resolveExactSkillPackage(
|
||||
"jyotish-birth-time-rectification",
|
||||
"10.0.1",
|
||||
"1fcdbeef6a2ad96f564f01aa9b9186dfdffe0ea8600748069528fd6a02236b33",
|
||||
);
|
||||
assert.equal(deprecated.status, "deprecated");
|
||||
const plateau = latestResultToolProjection({
|
||||
resultId: RESULT_ID,
|
||||
candidates: [
|
||||
{ candidateId: CANDIDATE_ID, time: "04:45", rank: 1, relativeSupport: 40, tiedMinuteCount: 25 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, time: "04:46", rank: 2, relativeSupport: 35, tiedMinuteCount: 25 },
|
||||
{ candidateId: THIRD_CANDIDATE_ID, time: "04:47", rank: 3, relativeSupport: 25, tiedMinuteCount: 25 },
|
||||
],
|
||||
selectionAllowed: true,
|
||||
confirmationAllowed: true,
|
||||
representativeTime: "04:45",
|
||||
selectedTime: null,
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
});
|
||||
assert.equal(plateau.confirmation_allowed, false);
|
||||
const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8");
|
||||
assert.match(skill, /method_followup_plan/);
|
||||
assert.match(skill, /不得给用户贴 D9\/D10 星座或类型标签/);
|
||||
assert.doesNotMatch(skill, /±5 分钟确定性/);
|
||||
});
|
||||
Reference in New Issue
Block a user