fix(rectification): stop re-asking covered domains after adopt (BUG-590, BUG-591)
Yearless-to-collect now skips domains already confirmed, declined, or asked; adopt-exhausted turns skip leftover collect persist. Collect focus collisions only retry with :next when collect_retry is set. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2146,7 +2146,7 @@ test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt"
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
test("answered duty language skips window D10 and collects a dated education event instead of a yearless D24 card", () => {
|
||||
test("answered duty language skips window D10 and does not re-ask covered education from a yearless D24 card", () => {
|
||||
const packet = {
|
||||
candidateSetVersion: "05:00-05:07",
|
||||
vargaDifferences: [
|
||||
@@ -2185,11 +2185,13 @@ test("answered duty language skips window D10 and collects a dated education eve
|
||||
evidence,
|
||||
contrastPacket: packet,
|
||||
});
|
||||
assert.equal(plan.next_followup?.domain, "education");
|
||||
// 原值: education(yearless D24 直接变成学业采集)
|
||||
// 新值: finance
|
||||
// 原因: CLASSIC_COVERAGE 已有学业;BUG-590 跳过已覆盖领域,改收未覆盖的财务
|
||||
assert.equal(plan.next_followup?.domain, "finance");
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(plan.next_followup?.choice_frame, null);
|
||||
assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住年份/);
|
||||
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/);
|
||||
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d(10|24)/);
|
||||
});
|
||||
|
||||
test("yearless D24 yields to a dated career dasha instead of borrowing the recorded education year", () => {
|
||||
|
||||
@@ -7,15 +7,31 @@ import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.
|
||||
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { buildMethodFollowupPlan, holdoutFollowupFor } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import {
|
||||
canShowRectificationSelectionCards,
|
||||
parseRectificationCandidateResult,
|
||||
workingRectificationHouseTable,
|
||||
} from "../src/lib/rectification-candidate-result.ts";
|
||||
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { interviewQuestionBlocksAdoptOffer } from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
|
||||
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
USER_ID,
|
||||
candidateSnapshotFixture,
|
||||
computeFixture,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
const STYLE_OPTIONS = [
|
||||
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
|
||||
@@ -503,3 +519,343 @@ test("collect focus is still not a renderable choice card", () => {
|
||||
probe_id: null,
|
||||
}), false);
|
||||
});
|
||||
|
||||
const ACCIDENT_EVIDENCE = [
|
||||
dated("e-edu-1", "education", "education_start", "2016-09-01"),
|
||||
dated("e-edu-2", "education", "education_completion", "2020-06-01"),
|
||||
dated("e-rel-1", "relationship", "relationship_start", "2018-05-01"),
|
||||
dated("e-rel-2", "relationship", "relationship_end", "2021-08-01"),
|
||||
dated("e-fin-1", "finance", "finance_change", "2024-03-01"),
|
||||
dated("e-career-1", "career", "career_entry", "2020-04-01"),
|
||||
dated("e-career-2", "career", "career_change", "2023-07-01"),
|
||||
dated("e-reloc-1", "relocation", "home_change", "2022-10-01"),
|
||||
dated("e-health-1", "health", "self_health_event", "2021-11-01"),
|
||||
{
|
||||
id: "e-occ-1",
|
||||
status: "confirmed" as const,
|
||||
domain: "occupation",
|
||||
datePrecision: "unknown" as const,
|
||||
occurredFrom: null,
|
||||
occurredTo: null,
|
||||
eventKind: "occupation_note",
|
||||
},
|
||||
];
|
||||
|
||||
const FAMILY_DECLINED_TOPIC = [{
|
||||
target_domain: "family",
|
||||
status: "declined",
|
||||
questionId: "collect:family:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
}];
|
||||
|
||||
const CLOSED_FINANCE_FOCUS = [{
|
||||
questionId: "collect:finance:collect_method_evidence",
|
||||
targetDomain: "finance",
|
||||
intent: "collect_method_evidence",
|
||||
status: "resolved",
|
||||
}];
|
||||
|
||||
const ACCIDENT_YEARLESS = {
|
||||
candidateSetVersion: "04:51-04:53",
|
||||
vargaDifferences: [] as const,
|
||||
probes: [
|
||||
yearlessProbe("d11", 2.5, "finance"),
|
||||
yearlessProbe("d2", 2.2, "finance"),
|
||||
yearlessProbe("d24", 1.2, "education"),
|
||||
yearlessProbe("d5", 0.5, "education"),
|
||||
yearlessProbe("d4", 0.99, "relocation"),
|
||||
yearlessProbe("d7", 1.22, "family"),
|
||||
yearlessProbe("d12", 1.1, "family"),
|
||||
],
|
||||
};
|
||||
|
||||
const ANSWERED_SIX = [
|
||||
{ semantic_key: "varga.d9.天秤座|天蝎座", answer_class: "yes" },
|
||||
{ semantic_key: "varga.d10.巨蟹座|狮子座", answer_class: "yes" },
|
||||
{ semantic_key: "career.2020", answer_class: "yes" },
|
||||
{ semantic_key: "career.2023", answer_class: "no" },
|
||||
{ semantic_key: "relocation.2022", answer_class: "no" },
|
||||
{ semantic_key: "finance.2024", answer_class: "no" },
|
||||
];
|
||||
|
||||
function coveredDomainPlan(
|
||||
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
|
||||
) {
|
||||
return buildMethodFollowupPlan({
|
||||
evidence: ACCIDENT_EVIDENCE,
|
||||
contrastPacket: ACCIDENT_YEARLESS,
|
||||
declinedTopics: FAMILY_DECLINED_TOPIC,
|
||||
closedCollectFocuses: CLOSED_FINANCE_FOCUS,
|
||||
answeredProbes: ANSWERED_SIX,
|
||||
sessionOutcome: "adopt_representative",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test("yearless-to-collect does not re-ask a covered domain after occupation is done", () => {
|
||||
const plan = coveredDomainPlan();
|
||||
// 原值: next_followup.domain = finance(L2224 取 yearless[0]=D11)
|
||||
// 新值: null
|
||||
// 原因: 财务已有带年月事件且采集焦点已关闭;守卫跳过已覆盖领域后交付
|
||||
assert.equal(plan.next_followup, null);
|
||||
});
|
||||
|
||||
test("yearless-to-collect still asks an uncovered relocation domain", () => {
|
||||
const plan = coveredDomainPlan({
|
||||
evidence: ACCIDENT_EVIDENCE.filter((item) => item.domain !== "relocation"),
|
||||
closedCollectFocuses: CLOSED_FINANCE_FOCUS,
|
||||
});
|
||||
// 原值: finance(D11 仍排第一)
|
||||
// 新值: relocation
|
||||
// 原因: 守卫只跳过已覆盖领域,不是删 yearless→采集分支
|
||||
assert.equal(plan.next_followup?.domain, "relocation");
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(plan.next_followup?.choice_frame, null);
|
||||
});
|
||||
|
||||
function rpcAccidentEvidence(rows: readonly typeof ACCIDENT_EVIDENCE[number][]) {
|
||||
return rows.map((item, index) => ({
|
||||
id: item.id ?? `e-${index}`,
|
||||
source_turn_id: "33333333-3333-4333-8333-333333333333",
|
||||
subject: "self",
|
||||
event_kind: item.eventKind ?? item.domain,
|
||||
domain: item.domain,
|
||||
occurred_from: item.occurredFrom,
|
||||
occurred_to: item.occurredTo,
|
||||
date_precision: item.datePrecision,
|
||||
summary: "",
|
||||
status: item.status,
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-09-08T00:00:00.000Z",
|
||||
}));
|
||||
}
|
||||
|
||||
function accidentEvidenceFingerprint() {
|
||||
return evidenceLedgerFingerprint(rpcAccidentEvidence(ACCIDENT_EVIDENCE).map((item) => ({
|
||||
id: item.id,
|
||||
eventKind: item.event_kind,
|
||||
domain: item.domain,
|
||||
occurredFrom: item.occurred_from,
|
||||
occurredTo: item.occurred_to,
|
||||
datePrecision: item.date_precision,
|
||||
summary: item.summary,
|
||||
status: item.status,
|
||||
})) as never);
|
||||
}
|
||||
|
||||
function yearlessInferenceProbe(layer: string, domain: string, gain: number) {
|
||||
return {
|
||||
id: `contrast:varga.${layer}.unsigned`,
|
||||
semantic_key: `varga.${layer}.unsigned`,
|
||||
candidate_split_hash: `varga.${layer}.04:51/04:53`,
|
||||
domain,
|
||||
year: 0,
|
||||
question: `当前几个候选在 ${layer} 上还分得开。`,
|
||||
candidate_ids: ["04:51", "04:53"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["04:53"] },
|
||||
{ answer_class: "no" as const, supports: ["04:53"], conflicts: ["04:51"] },
|
||||
],
|
||||
information_gain: gain,
|
||||
source: "varga_contrast",
|
||||
choice_kind: "existence" as const,
|
||||
style_options: STYLE_OPTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
function accidentIdleState() {
|
||||
const times = ["04:51", "04:52", "04:53"] as const;
|
||||
const raw = {
|
||||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||||
candidate_set_id: candidateSetId("04:51", "04:53", times),
|
||||
revision: 6,
|
||||
phase: "discrimination" as const,
|
||||
result_status: "discriminating" as const,
|
||||
range_start: "04:51",
|
||||
range_end: "04:53",
|
||||
candidates: times.map((time, index) => ({
|
||||
id: time,
|
||||
time,
|
||||
cluster_range: [time, time] as const,
|
||||
prior_score: 30 - index,
|
||||
posterior_score: 30 - index,
|
||||
probability: index === 0 ? 0.5 : 0.25,
|
||||
status: "active" as const,
|
||||
rank: index + 1,
|
||||
strong_conflict_count: 0,
|
||||
})),
|
||||
events: [
|
||||
{ id: "e-edu-1", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
|
||||
{ id: "e-edu-2", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
|
||||
{ id: "e-rel-1", domain: "relationship", year: 2018, precision: "month" as const, usage: "training" as const },
|
||||
{ id: "e-rel-2", domain: "relationship", year: 2021, precision: "month" as const, usage: "holdout" as const },
|
||||
{ id: "e-fin-1", domain: "finance", year: 2024, precision: "month" as const, usage: "holdout" as const },
|
||||
{ id: "e-career-1", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
|
||||
{ id: "e-career-2", domain: "career", year: 2023, precision: "month" as const, usage: "training" as const },
|
||||
{ id: "e-reloc-1", domain: "relocation", year: 2022, precision: "month" as const, usage: "training" as const },
|
||||
{ id: "e-health-1", domain: "health", year: 2021, precision: "month" as const, usage: "training" as const },
|
||||
],
|
||||
probes: [
|
||||
yearlessInferenceProbe("d11", "finance", 2.5),
|
||||
yearlessInferenceProbe("d24", "education", 1.2),
|
||||
yearlessInferenceProbe("d4", "relocation", 0.99),
|
||||
yearlessInferenceProbe("d7", "family", 1.22),
|
||||
],
|
||||
answered_probes: ANSWERED_SIX.map((item) => ({
|
||||
probe_id: `probe:${item.semantic_key}`,
|
||||
semantic_key: item.semantic_key,
|
||||
candidate_split_hash: item.semantic_key,
|
||||
answer_class: item.answer_class as "yes" | "no",
|
||||
classified_from: "choice" as const,
|
||||
})),
|
||||
rounds: ANSWERED_SIX.map((item, index) => ({
|
||||
round: index + 1,
|
||||
phase: "discrimination" as const,
|
||||
probe_id: `probe:${item.semantic_key}`,
|
||||
scores_before: { "04:51": 30, "04:52": 29, "04:53": 28 },
|
||||
scores_after: { "04:51": 32, "04:52": 29, "04:53": 28 },
|
||||
entropy_before: 1.1,
|
||||
entropy_after: 1.0,
|
||||
eliminated_ids: [] as string[],
|
||||
winner_id: null,
|
||||
kind: "informative" as const,
|
||||
})),
|
||||
last_inference_round: null,
|
||||
entropy: 1.0,
|
||||
representative_time: "04:51",
|
||||
credible_range: ["04:51", "04:53"] as const,
|
||||
holdout_passed: null,
|
||||
};
|
||||
const loaded = asInferenceState(raw);
|
||||
assert.ok(loaded, "covered-domain accident inference must parse");
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function accidentIdleDossier() {
|
||||
const fingerprint = accidentEvidenceFingerprint();
|
||||
const state = accidentIdleState();
|
||||
return dossierFixture({
|
||||
evidence: rpcAccidentEvidence(ACCIDENT_EVIDENCE),
|
||||
evidenceCount: ACCIDENT_EVIDENCE.length,
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "04:51",
|
||||
evidenceLedgerFingerprint: fingerprint,
|
||||
candidates: [
|
||||
{ candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 40, tied_minute_count: 1 },
|
||||
{ candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "04:52", relative_support: 35, tied_minute_count: 1 },
|
||||
{ candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "04:53", relative_support: 25, tied_minute_count: 1 },
|
||||
],
|
||||
decisionReceipt: {
|
||||
accept_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: state,
|
||||
},
|
||||
}),
|
||||
conversationSummary: {
|
||||
confirmed_evidence_summary: [],
|
||||
pending_revisions: [],
|
||||
active_focus: null,
|
||||
declined_skipped_topics: FAMILY_DECLINED_TOPIC,
|
||||
candidate_divergence_summary: null,
|
||||
missing_evidence_categories: [],
|
||||
last_result_policy: null,
|
||||
summary_version: 1,
|
||||
updated_at: "2026-09-08T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("idle persist on the covered-domain accident delivers adopt and writes no focus", async () => {
|
||||
const rpc = accidentIdleDossier();
|
||||
const dossier = parseV9CaseDossier(rpc);
|
||||
assert.ok(dossier, "covered-domain accident RPC dossier must parse");
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(decision.stopReason, "probe_pool_exhausted");
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpc,
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
throw new Error(`must not persist ${String(args.p_question_id)}`);
|
||||
},
|
||||
append_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", idempotent: false }),
|
||||
finalize_agentic_rectification_turn: () => ({
|
||||
turn_id: "33333333-3333-4333-8333-333333333333",
|
||||
status: "completed",
|
||||
idempotent: false,
|
||||
}),
|
||||
get_agentic_rectification_turn_receipt: () => null,
|
||||
});
|
||||
const idle = await persistNextInterviewIfIdle({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(
|
||||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
assert.equal(idle.terminalNote, true);
|
||||
assert.ok(idle.hostNarration);
|
||||
assert.equal(projectCurrentQuestion(null), null);
|
||||
assert.equal(interviewQuestionBlocksAdoptOffer(null, false), false);
|
||||
});
|
||||
|
||||
test("agent-run still writes the exhaustion gate when idle persist returns terminalNote", () => {
|
||||
const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(agent, /idle\.terminalNote && idle\.hostNarration/);
|
||||
assert.match(agent, /persistExhaustionGateTurn/);
|
||||
assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/);
|
||||
const offer = canShowRectificationSelectionCards(
|
||||
parseRectificationCandidateResult({
|
||||
resultId: "55555555-5555-4555-8555-555555555555",
|
||||
candidates: [
|
||||
{ candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 1 },
|
||||
{ candidateId: CANDIDATE_IDS[1], rank: 2, time: "04:53", relativeSupport: 35, tiedMinuteCount: 1 },
|
||||
],
|
||||
overallConfidence: "medium",
|
||||
selectionAllowed: true,
|
||||
canAdopt: true,
|
||||
confirmationAllowed: false,
|
||||
representativeTime: "04:51",
|
||||
selectedTime: null,
|
||||
sessionOutcome: "adopt_representative",
|
||||
decisionReceipt: {
|
||||
accept_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(offer, true);
|
||||
});
|
||||
|
||||
test("holdout occupied treats health and health_pressure as the same line", () => {
|
||||
const evidence = [
|
||||
...ACCIDENT_EVIDENCE.filter((item) => item.domain !== "health"),
|
||||
dated("e-health-ledger", "health", "self_health_event", "2021-11-01"),
|
||||
];
|
||||
const fields = holdoutFollowupFor({
|
||||
evidence,
|
||||
oosBlindPrompts: [{
|
||||
domain: "health_pressure",
|
||||
user_meaning: "身体这条线还没用过。",
|
||||
used_for_scoring: false,
|
||||
}],
|
||||
}, new Set());
|
||||
// 原值: health_pressure
|
||||
// 新值: null
|
||||
// 原因: occupied 不认 health / health_pressure 同义时会再问一遍健康
|
||||
assert.equal(fields, null);
|
||||
});
|
||||
|
||||
|
||||
@@ -784,8 +784,10 @@ test("collect persist maps occupation to other and health_pressure to health", a
|
||||
assert.equal(healthWrite?.args.p_target_domain, "health");
|
||||
});
|
||||
|
||||
test("collect focus unique conflict retries with a :next question id", async () => {
|
||||
const followup = collectFollowup();
|
||||
test("collect focus unique conflict is duplicate_focus unless collect_retry", async () => {
|
||||
// 原值: 任何撞 id 都 :next
|
||||
// 新值: 仅 collect_retry 才 :next;否则 duplicate_focus、RPC 一次
|
||||
// 原因: BUG-591 决策 3;撞 id 本身就是这题问过了
|
||||
let writes = 0;
|
||||
const accounting = fakeAccounting({
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
@@ -800,7 +802,29 @@ test("collect focus unique conflict retries with a :next question id", async ()
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: null,
|
||||
followup,
|
||||
followup: collectFollowup(),
|
||||
});
|
||||
assert.equal(persisted.status, "duplicate_focus");
|
||||
assert.equal(writes, 1);
|
||||
assert.equal(persisted.questionId, "collect:relationship:collect_method_evidence");
|
||||
});
|
||||
|
||||
test("collect_retry unique conflict still retries with a :next question id", async () => {
|
||||
let writes = 0;
|
||||
const accounting = fakeAccounting({
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
writes += 1;
|
||||
if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict");
|
||||
return focusRowFromArgs(args);
|
||||
},
|
||||
});
|
||||
const persisted = await persistServerOwnedFocus({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: null,
|
||||
followup: collectFollowup({ collect_retry: true }),
|
||||
});
|
||||
assert.equal(persisted.status, "created");
|
||||
assert.equal(writes, 2);
|
||||
|
||||
Reference in New Issue
Block a user