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
co-authored by Cursor
parent 946f11185e
commit a658ef8d2b
18 changed files with 1019 additions and 141 deletions
@@ -11,6 +11,7 @@ import {
parseAgentChoiceCopy,
parseChoiceKeyFromUserMessage,
parseRectificationChoiceCard,
serverOwnedChoiceCopy,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
@@ -74,6 +75,22 @@ test("choice frames ask one biographical event from a server probe, not competin
assert.equal(mergeChoiceCard(frame, null), null);
});
test("server-owned card names the event family, not a generic 有没有这件事", () => {
const frame = buildChoiceFrame(
{
method_id: "d4_home",
ask_theme: "home_change",
domain: "relocation",
user_prompt_hint: "unused",
},
{ probes: [MOVE_PROBE] },
);
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /搬家、离乡或长期异地/);
assert.doesNotMatch(copy.prompt, /有没有这件事/);
});
test("the visible card uses Agent copy for A/B/C/D, not the server hint", () => {
const frame = buildChoiceFrame({
method_id: "d4_home",
@@ -580,6 +597,8 @@ test("GET choice_card stays after coverage when remaining minutes still split on
propose_allowed: true,
window_scan: {
scanned: true,
d9_lagna_count: 1,
d10_lagna_count: 3,
d10_candidates_differ: true,
d10_sign_names: ["巨蟹座", "狮子座", "处女座"],
d24_candidates_differ: true,
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
selectDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
@@ -111,7 +112,7 @@ test("missing method coverage stays in fact collection even if scores look separ
assert.equal(next.type, "ask_fact_collection");
});
test("D9/D10 sign differences synthesize a contrast probe when engine probes are empty", () => {
test("whole-window D9/D10 signs do not synthesize a discriminator without remaining-minute splits", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "set-a",
calculationResultId: "22222222-2222-4222-8222-222222222222",
@@ -120,11 +121,56 @@ test("D9/D10 sign differences synthesize a contrast probe when engine probes are
{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] },
],
});
assert.equal(selectDiscriminatorProbe(packet), null);
});
test("exam quality and occupation notes skip remaining D24/D10 and keep D4", () => {
const evidence = [
{ domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" },
{ domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员 / 前端", status: "confirmed" },
];
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
calculationResultId: "22222222-2222-4222-8222-222222222222",
candidateTimes: ["05:00", "05:03", "05:04"],
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" },
],
askedKeys: askedKeysFromLedgerEvidence(evidence),
});
const probe = selectDiscriminatorProbe(packet);
assert.ok(probe);
assert.ok(probe.expectedOutcomes.length >= 2);
assert.equal(probe.sourceFeatures[0]?.calculationResultId, "22222222-2222-4222-8222-222222222222");
assert.match(probe.question, /职业前事|事业盘/);
assert.match(probe.semanticKey, /varga\.d4/);
assert.equal(probe.domain, "relocation");
assert.match(probe.question, /搬家|离乡/);
});
test("encoded D24/D10/D4 remaining splits leave no discriminator", () => {
const evidence = [
{ domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" },
{ domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员", status: "confirmed" },
{ domain: "relocation", eventKind: "home_change", summary: "搬家离乡", status: "confirmed" },
];
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:03", "05:04"],
transitions: [
{ layer: "d4", at: "05:00" },
{ layer: "d4", at: "05:03" },
{ layer: "d10", at: "05:00" },
{ layer: "d24", at: "05:00" },
],
askedKeys: askedKeysFromLedgerEvidence(evidence),
});
assert.equal(selectDiscriminatorProbe(packet), null);
});
test("remaining-candidate D24 split beats window D10 signs", () => {
@@ -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,
);
});
@@ -70,6 +70,29 @@ function candidates(scores: Readonly<Record<string, number>>): InferenceCandidat
}));
}
test("discriminating result does not stay in event_collection", () => {
const state = buildInferenceState({
range_start: "04:50",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 34 },
{ id: "05:03", time: "05:03", relative_support: 33 },
{ id: "05:04", time: "05:04", relative_support: 33 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "year" },
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
{ id: "e4", domain: "family", year: 2023, precision: "year" },
{ id: "e5", domain: "relocation", year: 2017, precision: "year" },
],
probes: [probe({ id: "open", gain: 0.3, yesSupports: ["05:00"], yesConflicts: ["05:03"] })],
phase: "event_collection",
});
assert.equal(state.result_status, "discriminating");
assert.equal(state.phase, "discrimination");
});
test("an informative answer lowers entropy and cannot revive an eliminated candidate", () => {
const conflict = probe({
id: "p1",
@@ -73,8 +73,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/);
assert.match(prompt, /skill_verification_report/);
assert.match(prompt, /ask_candidate_discriminator/);
assert.match(prompt, /方法覆盖已齐只进入候选区分/);
assert.doesNotMatch(prompt, /方法覆盖已齐且 propose_allowed 时本轮 adopt/);
assert.match(prompt, /offer_provisional_range/);
assert.match(prompt, /不要再问整窗 D9\/D24/);
assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/);
assert.match(prompt, /不要调用 rectification-set-focus/);
assert.match(prompt, /open_question\.prompt/);
@@ -83,6 +83,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /verify_adopted_time/);
assert.match(prompt, /event_probe/);
assert.match(prompt, /不得发明年份/);
assert.match(prompt, /不得根据出生年推算高考或入学年份/);
assert.match(prompt, /不要再问那一件发生在哪一年/);
assert.doesNotMatch(prompt, /两套盘各自的前事/);
assert.doesNotMatch(prompt, /外貌、体质、胎记或疤痕可以问/);
assert.doesNotMatch(prompt, /分盘句和宫位表由界面展示/);
@@ -1254,6 +1254,31 @@ test("PR-4 exposes only the explicit V2 service-role RPC signatures", () => {
);
});
test("kind-date evidence dedup stays out of the identity foundation", () => {
const migration = readFileSync(
new URL("../supabase/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql", import.meta.url),
"utf8",
);
const dbCopy = fileURLToPath(
new URL("../db/migrations/20260825010000_rectification_evidence_kind_date_dedup.sql", import.meta.url),
);
assert.equal(
existsSync(dbCopy),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
assert.match(migration, /^begin;[\s\S]*^commit;$/m);
assert.match(
migration,
/Same dated event restated with a new quote must not insert a second row/,
);
assert.match(
migration,
/e\.event_kind = v_kind[\s\S]*e\.occurred_from is not distinct from v_from/,
);
assert.match(migration, /grant execute on function public\.record_agentic_rectification_evidence_batch/);
});
test("turn origin migration stays out of the identity foundation", () => {
const turnOriginMigration = readFileSync(
new URL("../supabase/migrations/20260824030000_rectification_turn_origin.sql", import.meta.url),