fix(web): stop rectification interview when remaining minutes no longer split

Coverage-complete ties stayed in discrimination because whole-window D9/D24 follow-ups were treated as probes, and restated dates inserted duplicate evidence. Skip encoded remaining layers, ask leftover D4 or offer a provisional range, and dedupe dated rows by kind and date.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 19:02:35 +08:00
parent 946f11185e
commit a658ef8d2b
18 changed files with 1019 additions and 141 deletions
@@ -3,6 +3,10 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, latestUserStoppedCollecting } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import {
internalObservationsFromWindowScan,
parseWindowScan,
@@ -814,7 +818,7 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
});
test("lagna_frame after classic coverage still discriminates when candidates are tied", () => {
test("lagna_frame after classic coverage does not keep a tie in discrimination without a remaining split", () => {
const plan = buildMethodFollowupPlan({
evidence: CLASSIC_COVERAGE,
precisionStage: "lagna_frame",
@@ -829,12 +833,13 @@ test("lagna_frame after classic coverage still discriminates when candidates are
confirmationAllowed: false,
nextFollowup: plan.next_followup,
methods: plan.methods,
discriminatorProbe: null,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:01", score: 33 },
{ time: "05:02", score: 33 },
],
}), "discriminate_candidates");
}), "provisional_range");
});
test("precision stage d4 asks home change not family, and d5 asks education", () => {
@@ -1188,6 +1193,181 @@ test("answered duty language skips window D10 and uses remaining D24", () => {
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/);
});
const DUMP_COVERAGE = [
{
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
summary: "上大学",
},
{
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2015-06-01",
occurredTo: null,
eventKind: "education_interruption",
summary: "高考失利复读",
},
{
status: "confirmed" as const,
domain: "relationship",
datePrecision: "day" as const,
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "开始一段感情",
},
{
status: "confirmed" as const,
domain: "relationship",
datePrecision: "day" as const,
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
summary: "感情结束",
},
{
status: "confirmed" as const,
domain: "career",
datePrecision: "day" as const,
occurredFrom: "2024-04-07",
occurredTo: null,
eventKind: "career_entry",
summary: "入职",
},
{
status: "confirmed" as const,
domain: "family",
datePrecision: "month" as const,
occurredFrom: "2016-05-01",
occurredTo: null,
eventKind: "family_event",
summary: "家人变化",
},
{
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
summary: "互联网程序员 / 前端 / Agent 开发",
},
];
const DUMP_TRANSITIONS = [
{ layer: "d4", at: "05:00" },
{ layer: "d4", at: "05:03" },
{ layer: "d10", at: "05:00" },
{ layer: "d10", at: "05:03" },
{ layer: "d24", at: "05:00" },
{ layer: "d24", at: "05:03" },
{ layer: "d9", at: "04:52" },
{ layer: "d9", at: "05:08" },
{ layer: "d5", at: "05:15" },
];
const DUMP_SCORES = [
{ time: "05:00", score: 34 },
{ time: "05:03", score: 33 },
{ time: "05:04", score: 33 },
];
test("coverage-complete tie with encoded D24/D10 asks remaining D4, not enrollment or breakup", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: DUMP_SCORES.map((item) => item.time),
transitions: DUMP_TRANSITIONS,
askedKeys: askedKeysFromLedgerEvidence(DUMP_COVERAGE),
});
const plan = buildMethodFollowupPlan({
evidence: DUMP_COVERAGE,
contrastPacket: packet,
askedProbeKeys: askedKeysFromLedgerEvidence(DUMP_COVERAGE),
});
assert.equal(plan.next_followup?.domain, "relocation");
assert.equal(plan.next_followup?.kind_hint, "home_change");
assert.match(plan.next_followup?.semantic_key ?? "", /varga\.d4/);
assert.doesNotMatch(plan.next_followup?.kind_hint ?? "", /education_start|relationship_end/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /大学哪年入学|高考是 \d{4}|哪年毕业/);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: plan.next_followup,
methods: plan.methods,
discriminatorProbe: packet.probes[0] ?? null,
candidateScores: DUMP_SCORES,
}), "discriminate_candidates");
});
test("coverage-complete tie with no remaining split offers a provisional range", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: DUMP_SCORES.map((item) => item.time),
transitions: DUMP_TRANSITIONS,
askedKeys: askedKeysFromLedgerEvidence([
...DUMP_COVERAGE,
{
status: "confirmed",
domain: "relocation",
eventKind: "home_change",
summary: "搬家离乡",
},
]),
});
const plan = buildMethodFollowupPlan({
evidence: [
...DUMP_COVERAGE,
{
status: "confirmed" as const,
domain: "relocation",
datePrecision: "year" as const,
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "home_change",
summary: "搬家离乡",
},
],
contrastPacket: packet,
sessionOutcome: "provisional_range",
});
assert.equal(plan.next_followup, null);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "当前候选在关系主题上仍分不开。",
must_not_label: false,
choice_frame: null,
source: "varga_observation",
},
methods: plan.methods,
discriminatorProbe: null,
candidateScores: DUMP_SCORES,
}), "provisional_range");
const action = buildNextUserAction({
scorableCount: 6,
evidenceCount: 8,
hasLatestResult: true,
selectionAllowed: true,
sessionOutcome: "provisional_range",
nextFollowup: null,
workingTime: "05:00",
});
assert.equal(action.id, "offer_provisional_range");
});
test("没有了 is a user stop", () => {
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有了" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没了" }]), true);
@@ -1239,6 +1419,49 @@ test("same domain different year still asks a conflict probe", () => {
assert.equal(plan.next_followup?.domain, "career");
});
test("adjacent education year does not re-ask enrollment after a recorded start", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "education",
eventKind: "education_start",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
summary: "2016年9月上大学",
}, {
status: "confirmed",
domain: "relationship",
eventKind: "relationship_end",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: "2024-08-08",
summary: "一段感情结束",
}],
eventProbes: [{
year: 2015,
year_label: "2015 年前后",
domain: "education",
event_family: "升学、高考、转学或学习环境变化",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2015 年前后。事件家族:升学、高考、转学或学习环境变化。请写成一句自然语言是/否题。不得改年份。",
role: "reverse_verify",
information_gain: 0.21,
semantic_key: "education.2015.dasha_activation",
}],
});
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.source, "event_probe");
assert.equal(plan.next_followup?.method_id, "d10_career");
assert.match(plan.next_followup?.user_prompt_hint ?? "", /2016 年入学/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /2024 年感情结束/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要再问这些事发生在哪一年/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /高考是 2015/);
});
test("high information_gain leftover probe still blocks offering after coverage", () => {
const plan = buildMethodFollowupPlan({
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
@@ -1466,7 +1689,7 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
);
});
test("offer-candidates refuses a 34/33/33 tie after method coverage", async () => {
test("offer-candidates allows a 34/33/33 tie after method coverage when remaining minutes do not split", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
@@ -1559,11 +1782,16 @@ test("offer-candidates refuses a 34/33/33 tie after method coverage", async () =
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
() => (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
const projection = await (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<{ session_outcome: { kind: string } }>;
}).execute({ caseId: CASE_ID });
assert.equal(projection.session_outcome.kind, "provisional_range");
assert.equal(
accounting.calls.some((call) =>
call.fn === "transition_agentic_rectification_case_status"
&& call.args.p_status === "candidate_ready"
),
true,
);
});