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:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user