8e31680b45
Intake stores how sure the user is; rectification now searches that range, offers a one-click widen when event fit is low at the edge, and trisects windows longer than two hours before the minute grid. Co-authored-by: Cursor <cursoragent@cursor.com>
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { deriveRectificationOpenWindow } from "../src/lib/rectification-agentic/v9/case-service.ts";
|
|
import { decideAfterInferenceChange, decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
|
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
|
import { projectRectificationStepState } from "../src/lib/rectification-agentic/v9/step-state.ts";
|
|
import {
|
|
BLOCK_CHOICE_INTENT,
|
|
BLOCK_PERIOD_LABELS,
|
|
buildBlockChoiceFrame,
|
|
parseBlockScanPayload,
|
|
serializeBlockScanPayload,
|
|
windowFromBlockChoice,
|
|
} from "../src/lib/rectification-agentic/v9/block-scan.ts";
|
|
import { splitWindowIntoThirds } from "../src/lib/rectification-agentic/v9/search-window.ts";
|
|
import {
|
|
CASE_ID,
|
|
FOCUS_ID,
|
|
SESSION_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
activeFocusFixture,
|
|
computeFixture,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
const BLOCKS = [
|
|
{ period: "early_morning", start_time: "04:00", end_time: "07:59", relative_support: 12 },
|
|
{ period: "morning", start_time: "08:00", end_time: "11:59", relative_support: 41 },
|
|
{ period: "afternoon", start_time: "12:00", end_time: "17:59", relative_support: 28 },
|
|
{ period: "evening", start_time: "18:00", end_time: "22:59", relative_support: 11 },
|
|
{ period: "late_night", start_time: "23:00", end_time: "03:59", relative_support: 8 },
|
|
] as const;
|
|
|
|
const EVIDENCE = [
|
|
{
|
|
id: "e-edu",
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "education_start",
|
|
domain: "education",
|
|
occurred_from: "2016-09-01",
|
|
occurred_to: "2016-09-30",
|
|
date_precision: "month",
|
|
summary: "education start",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-06T00:00:00.000Z",
|
|
},
|
|
{
|
|
id: "e-rel",
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "relationship_end",
|
|
domain: "relationship",
|
|
occurred_from: "2024-08-08",
|
|
occurred_to: "2024-08-08",
|
|
date_precision: "day",
|
|
summary: "relationship end",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-06T00:00:01.000Z",
|
|
},
|
|
{
|
|
id: "e-move",
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "relocation",
|
|
domain: "relocation",
|
|
occurred_from: "2023-07-01",
|
|
occurred_to: "2023-07-31",
|
|
date_precision: "month",
|
|
summary: "relocation",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-06T00:00:02.000Z",
|
|
},
|
|
];
|
|
|
|
function blockPayload(extra: Record<string, unknown> = {}) {
|
|
return {
|
|
evidence_ledger_fingerprint: "f".repeat(64),
|
|
algorithm_version: "test",
|
|
minute_step: 10,
|
|
blocks: [...BLOCKS],
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function blockSchema() {
|
|
const payload = parseBlockScanPayload(blockPayload());
|
|
assert.ok(payload);
|
|
const frame = buildBlockChoiceFrame(payload);
|
|
assert.ok(frame);
|
|
return {
|
|
choice: {
|
|
prompt: frame.prompt,
|
|
option_a: frame.option_a_hint,
|
|
option_b: frame.option_b_hint,
|
|
option_c: frame.neither_label,
|
|
option_d: frame.unsure_label,
|
|
options: [
|
|
{ key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" },
|
|
{ key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" },
|
|
{ key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" },
|
|
{ key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" },
|
|
],
|
|
},
|
|
choice_kind: "block_choice",
|
|
scoring: false,
|
|
block_periods: {
|
|
A: BLOCKS[1],
|
|
B: BLOCKS[2],
|
|
C: BLOCKS[0],
|
|
},
|
|
};
|
|
}
|
|
|
|
function blockDossier(extra: {
|
|
declined?: boolean;
|
|
stage?: string;
|
|
range?: { start_time: string; end_time: string };
|
|
blockScan?: unknown;
|
|
} = {}) {
|
|
const schema = blockSchema();
|
|
return dossierFixture({
|
|
candidateRange: extra.range ?? { start_time: "00:00", end_time: "23:59" },
|
|
stage: extra.stage ?? "block_scan",
|
|
blockScan: extra.blockScan ?? (extra.declined
|
|
? blockPayload({ declined_at_fingerprint: "f".repeat(64), evidence_ledger_fingerprint: "f".repeat(64) })
|
|
: blockPayload()),
|
|
evidence: EVIDENCE,
|
|
conversationSummary: {
|
|
confirmed_evidence_summary: [],
|
|
pending_revisions: [],
|
|
active_focus: extra.stage === "minute"
|
|
? null
|
|
: activeFocusFixture({
|
|
intent: BLOCK_CHOICE_INTENT,
|
|
targetDomain: null,
|
|
targetKind: null,
|
|
questionId: "block_scan:choose_birth_block:holdout",
|
|
expectedAnswerSchema: schema,
|
|
}),
|
|
declined_skipped_topics: [],
|
|
candidate_divergence_summary: null,
|
|
missing_evidence_categories: [],
|
|
last_result_policy: null,
|
|
summary_version: 1,
|
|
updated_at: "2026-09-06T00:00:00.000Z",
|
|
},
|
|
});
|
|
}
|
|
|
|
test("unknown source opens a full-day block_scan window", () => {
|
|
assert.deepEqual(
|
|
deriveRectificationOpenWindow({
|
|
reportedTime: null,
|
|
source: "unknown",
|
|
period: null,
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
}),
|
|
{
|
|
candidateRange: { start_time: "00:00", end_time: "23:59" },
|
|
stage: "block_scan",
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
deriveRectificationOpenWindow({
|
|
reportedTime: "05:00",
|
|
source: "unknown",
|
|
period: null,
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
}).stage,
|
|
"minute",
|
|
);
|
|
assert.equal(
|
|
deriveRectificationOpenWindow({
|
|
reportedTime: "05:00",
|
|
source: "family_exact",
|
|
period: null,
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
}).stage,
|
|
"minute",
|
|
);
|
|
});
|
|
|
|
test("three dated events in two domains ask a block choice and never adopt", () => {
|
|
const parsed = parseV9CaseDossier(blockDossier());
|
|
assert.ok(parsed);
|
|
const decision = decideFromDossier(parsed);
|
|
assert.equal(decision.nextAction, "ask_block_choice");
|
|
assert.equal(decision.canAdopt, false);
|
|
assert.equal(decision.selectionAllowed, false);
|
|
assert.equal(decision.representativeTime, null);
|
|
assert.equal(decision.separation.ranked.length, 0);
|
|
const step = projectRectificationStepState({ nextAction: decision.nextAction });
|
|
assert.equal(step.index, 1);
|
|
assert.equal(step.stage, "collect");
|
|
assert.match(step.headline, /比较时段/);
|
|
});
|
|
|
|
test("block choice copy names periods, not a winning minute", () => {
|
|
const payload = parseBlockScanPayload(blockPayload());
|
|
assert.ok(payload);
|
|
const frame = buildBlockChoiceFrame(payload);
|
|
assert.ok(frame);
|
|
assert.match(frame.prompt, /哪一段更像出生时段/);
|
|
assert.doesNotMatch(frame.prompt, /更像\s*\d/);
|
|
assert.match(frame.option_a_hint ?? "", new RegExp(BLOCK_PERIOD_LABELS.morning));
|
|
assert.equal(frame.scoring, false);
|
|
assert.equal(frame.choice_kind, "block_choice");
|
|
const schema = blockSchema();
|
|
assert.deepEqual(windowFromBlockChoice({ schema, optionId: "B" }), {
|
|
start_time: "12:00",
|
|
end_time: "17:59",
|
|
});
|
|
assert.equal(windowFromBlockChoice({ schema, optionId: "D" }), null);
|
|
});
|
|
|
|
test("declined block_scan fingerprint returns collect instead of another card", () => {
|
|
const parsed = parseV9CaseDossier(blockDossier());
|
|
assert.ok(parsed);
|
|
const fingerprint = evidenceLedgerFingerprint(parsed.evidence);
|
|
const declined = parseV9CaseDossier(blockDossier({
|
|
blockScan: {
|
|
...blockPayload(),
|
|
declined_at_fingerprint: fingerprint,
|
|
},
|
|
}));
|
|
assert.ok(declined);
|
|
assert.equal(declined.case.blockScan?.declinedAtFingerprint, fingerprint);
|
|
const decision = decideAfterInferenceChange({
|
|
dossier: declined,
|
|
state: null,
|
|
userStopped: false,
|
|
});
|
|
assert.equal(decision.nextAction, "ask_fact_collection");
|
|
assert.equal(decision.canAdopt, false);
|
|
});
|
|
|
|
test("choosing afternoon from a full-day scan stays in block_scan for a window wider than 120 minutes", async () => {
|
|
let current = blockDossier();
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => current,
|
|
get_agentic_rectification_case_compute: () => computeFixture({
|
|
baselineBirthSnapshot: {
|
|
birth_date: "1998-03-15",
|
|
latitude: 39.9042,
|
|
longitude: 116.4074,
|
|
timezone_id: "Asia/Shanghai",
|
|
timezone_offset: 8,
|
|
birth_time_source: "unknown",
|
|
},
|
|
}),
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
|
apply_agentic_rectification_choice_action: (_fn, args) => ({
|
|
action_id: args.p_action_id,
|
|
status: "applied",
|
|
idempotent: false,
|
|
question_id: args.p_question_id,
|
|
option_id: args.p_option_id,
|
|
revision: 1,
|
|
narration: args.p_narration,
|
|
focus_status: args.p_focus_status,
|
|
}),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
case_id: CASE_ID,
|
|
question_id: args.p_question_id,
|
|
intent: args.p_intent,
|
|
expected_answer_schema: args.p_expected_answer_schema,
|
|
status: "active",
|
|
asked_at: "2026-09-06T00:00:00.000Z",
|
|
resolved_at: null,
|
|
},
|
|
idempotent: false,
|
|
}),
|
|
write_agentic_rectification_block_scan: (_fn, args) => {
|
|
current = blockDossier({
|
|
stage: "block_scan",
|
|
range: current.case.candidate_range as { start_time: string; end_time: string },
|
|
blockScan: args.p_block_scan,
|
|
});
|
|
return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan };
|
|
},
|
|
advance_agentic_rectification_case_from_block_scan: (_fn, args) => {
|
|
current = blockDossier({
|
|
stage: "block_scan",
|
|
range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) },
|
|
blockScan: { rounds: 0, blocks: [] },
|
|
});
|
|
return {
|
|
case_id: CASE_ID,
|
|
stage: "block_scan",
|
|
candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time },
|
|
};
|
|
},
|
|
persist_agentic_rectification_candidate_v2: () => {
|
|
throw new Error("must not persist the 24h minute snapshot");
|
|
},
|
|
});
|
|
const receipt = await applyRectificationChoice(accounting.client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
|
|
action: CHOICE_ACTION,
|
|
focusId: FOCUS_ID,
|
|
optionId: "B",
|
|
expectedRevision: 0,
|
|
});
|
|
assert.equal(receipt.snapshotCurrent, false);
|
|
assert.equal(receipt.nextAction.can_adopt, false);
|
|
const parsed = parseV9CaseDossier(current);
|
|
assert.equal(parsed?.case.stage, "block_scan");
|
|
assert.deepEqual(parsed?.case.candidateRange, { start_time: "12:00", end_time: "17:59" });
|
|
assert.ok(accounting.calls.some((item) => item.fn === "advance_agentic_rectification_case_from_block_scan"));
|
|
});
|
|
|
|
test("choosing D stays in block_scan and collects again", async () => {
|
|
let current = blockDossier();
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => current,
|
|
get_agentic_rectification_case_compute: () => computeFixture(),
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
|
apply_agentic_rectification_choice_action: (_fn, args) => ({
|
|
action_id: args.p_action_id,
|
|
status: "applied",
|
|
idempotent: false,
|
|
question_id: args.p_question_id,
|
|
option_id: args.p_option_id,
|
|
revision: 1,
|
|
narration: args.p_narration,
|
|
focus_status: args.p_focus_status,
|
|
}),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
case_id: CASE_ID,
|
|
question_id: args.p_question_id,
|
|
intent: args.p_intent,
|
|
expected_answer_schema: args.p_expected_answer_schema,
|
|
status: "active",
|
|
asked_at: "2026-09-06T00:00:00.000Z",
|
|
resolved_at: null,
|
|
},
|
|
idempotent: false,
|
|
}),
|
|
write_agentic_rectification_block_scan: (_fn, args) => {
|
|
current = blockDossier({ blockScan: args.p_block_scan });
|
|
return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan };
|
|
},
|
|
advance_agentic_rectification_case_from_block_scan: () => {
|
|
throw new Error("D must not advance the window");
|
|
},
|
|
});
|
|
const receipt = await applyRectificationChoice(accounting.client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
|
|
action: CHOICE_ACTION,
|
|
focusId: FOCUS_ID,
|
|
optionId: "D",
|
|
expectedRevision: 0,
|
|
});
|
|
const parsedAfter = parseV9CaseDossier(current);
|
|
assert.equal(parsedAfter?.case.stage, "block_scan");
|
|
assert.equal(
|
|
parsedAfter?.case.blockScan?.declinedAtFingerprint,
|
|
evidenceLedgerFingerprint(parsedAfter!.evidence),
|
|
);
|
|
assert.equal(receipt.nextAction.type, "ask_fact_collection");
|
|
assert.equal(receipt.nextAction.can_adopt, false);
|
|
assert.ok(accounting.calls.some((item) => item.fn === "write_agentic_rectification_block_scan"));
|
|
const payload = serializeBlockScanPayload(parseBlockScanPayload(blockPayload({
|
|
declined_at_fingerprint: "f".repeat(64),
|
|
}))!);
|
|
assert.equal(payload.declined_at_fingerprint, "f".repeat(64));
|
|
});
|
|
|
|
test("existing minute-stage cases keep the minute interview", () => {
|
|
const parsed = parseV9CaseDossier(dossierFixture({
|
|
evidence: EVIDENCE,
|
|
stage: "minute",
|
|
candidateRange: { start_time: "04:50", end_time: "05:10" },
|
|
}));
|
|
assert.ok(parsed);
|
|
const decision = decideFromDossier(parsed);
|
|
assert.notEqual(decision.nextAction, "ask_block_choice");
|
|
assert.notEqual(decision.sessionOutcome, "compare_blocks");
|
|
});
|
|
|
|
test("period_only evening and approximate ±120 open as block_scan, custom 30 minutes stay minute", () => {
|
|
assert.deepEqual(
|
|
deriveRectificationOpenWindow({
|
|
reportedTime: null,
|
|
source: "period_only",
|
|
period: "evening",
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
}),
|
|
{
|
|
candidateRange: { start_time: "18:00", end_time: "22:59" },
|
|
stage: "block_scan",
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
deriveRectificationOpenWindow({
|
|
reportedTime: "05:00",
|
|
source: "approximate",
|
|
period: null,
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
uncertaintyBefore: 120,
|
|
uncertaintyAfter: 120,
|
|
}),
|
|
{
|
|
candidateRange: { start_time: "03:00", end_time: "07:00" },
|
|
stage: "block_scan",
|
|
},
|
|
);
|
|
assert.equal(
|
|
deriveRectificationOpenWindow({
|
|
reportedTime: null,
|
|
source: "period_only",
|
|
period: null,
|
|
windowStart: "14:30",
|
|
windowEnd: "15:00",
|
|
}).stage,
|
|
"minute",
|
|
);
|
|
});
|
|
|
|
test("advance stubs with empty blocks still parse rounds", () => {
|
|
const parsed = parseBlockScanPayload({ rounds: 0, blocks: [] });
|
|
assert.equal(parsed?.rounds, 0);
|
|
assert.deepEqual(parsed?.blocks, []);
|
|
assert.equal(parseBlockScanPayload({ blocks: [] }), null);
|
|
});
|
|
|
|
test("evening thirds become a sub-block card with clock labels", () => {
|
|
const thirds = splitWindowIntoThirds({ start_time: "18:00", end_time: "22:59" });
|
|
const payload = parseBlockScanPayload({
|
|
rounds: 1,
|
|
blocks: thirds.map((block, index) => ({
|
|
period: block.period,
|
|
start_time: block.start_time,
|
|
end_time: block.end_time,
|
|
relative_support: [40, 35, 25][index],
|
|
})),
|
|
});
|
|
assert.ok(payload);
|
|
const frame = buildBlockChoiceFrame(payload);
|
|
assert.ok(frame);
|
|
assert.match(frame.prompt, /更像出生时间/);
|
|
assert.match(frame.option_a_hint ?? "", /18:00/);
|
|
assert.doesNotMatch(frame.option_a_hint ?? "", /傍晚到晚上/);
|
|
});
|