Files
Jyotisha/frontend/tests/rectification-occupation-dated-answer-20260911.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

461 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
applyOccupationCollectLedgerNorm,
isOccupationCollectFocus,
isPrimaryScoreableEvidence,
trainingScoreableGate,
type EvidenceKind,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
CANDIDATE_ID,
FOCUS_ID,
SECOND_CANDIDATE_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const OCCUPATION_FOCUS = {
intent: "collect_method_evidence" as const,
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: "collect:occupation:collect_method_evidence",
};
const NOTE_ID = "44444444-4444-4444-8444-444444444451";
const CAREER_ID = "44444444-4444-4444-8444-444444444452";
const EDUCATION_ID = "44444444-4444-4444-8444-444444444441";
const ENGINE_SCORE = {
success: true,
endpoint: "rectification_v5_score",
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
// 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同不变。
algorithm_version: "rectification-v5",
event_contract_version: "rectification-event-contract-v2",
decision_policy_version: "rectification-candidate-policy-v2",
execution_ledger_version: "rectification-execution-ledger-v2",
candidate_decisions: [
{ candidate_id: CANDIDATE_ID, time: "04:50", rank: 1, relative_support: 57, tied_minute_count: 1 },
{ candidate_id: SECOND_CANDIDATE_ID, time: "04:51", rank: 2, relative_support: 25, tied_minute_count: 2 },
],
decision_receipt: {
receipt_version: "candidate-decision-receipt-v2",
contract_version: "v2",
event_contract_version: "rectification-event-contract-v2",
policy_version: "rectification-candidate-policy-v2",
decision_policy_version: "rectification-candidate-policy-v2",
display_allowed: true,
selection_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
accept_allowed: true,
confirm_allowed: false,
representative_candidate_id: CANDIDATE_ID,
representative_time: "04:50",
overall_confidence: "high",
margin_percent: 42.5,
gates: {
event_quality: { scoreable_event_count: 3, minimum: 3 },
domain_diversity: { domains: ["education", "career"], count: 2, minimum: 2 },
},
},
execution_ledger: [
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" },
],
diagnostics: {
window_scan: {
scanned: true,
confirmation_allowed: false,
unique_minute_claim: false,
d9_lagna_count: 2,
d10_lagna_count: 1,
d9_candidates_differ: true,
d10_candidates_differ: false,
d9_sign_names: ["白羊座", "天蝎"],
},
},
};
function stubEngine(response: unknown) {
const previous = globalThis.fetch;
globalThis.fetch = (async () => ({
ok: true,
status: 200,
json: async () => response,
})) as unknown as typeof fetch;
return () => {
globalThis.fetch = previous;
};
}
const educationEvidence = {
id: EDUCATION_ID,
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: "2016年9月上大学",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
};
function datedOccupationInput(kind: EvidenceKind = "career_entry") {
return {
domain: "career" as const,
eventKind: kind,
datePrecision: "month" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
summary: "2024年4月开始做程序员",
quote: "2024 年 4 月开始做程序员",
subject: "self" as const,
};
}
test("occupation collect focus is identified from question id, not body text", () => {
assert.equal(isOccupationCollectFocus(OCCUPATION_FOCUS), true);
assert.equal(isOccupationCollectFocus({
intent: "collect_method_evidence",
targetDomain: "career",
targetKind: "anchor:education_completion:2020",
questionId: "collect:anchor:education_completion:2020",
}), false);
});
test("dated occupation collect writes occupation_note plus a scoreable career_entry", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [datedOccupationInput()]);
assert.equal(remapped.length, 2);
assert.equal(remapped[0]?.domain, "occupation");
assert.equal(remapped[0]?.eventKind, "occupation_note");
assert.equal(remapped[0]?.datePrecision, "unknown");
assert.equal(remapped[0]?.occurredFrom, null);
assert.equal(remapped[0]?.occurredTo, null);
assert.equal(remapped[1]?.domain, "career");
assert.equal(remapped[1]?.eventKind, "career_entry");
assert.equal(remapped[1]?.datePrecision, "month");
assert.equal(remapped[1]?.occurredFrom, "2024-04-01");
assert.equal(isPrimaryScoreableEvidence({
status: "confirmed",
domain: remapped[0]!.domain,
datePrecision: remapped[0]!.datePrecision,
occurredFrom: remapped[0]!.occurredFrom,
occurredTo: remapped[0]!.occurredTo,
eventKind: remapped[0]!.eventKind,
}), false);
assert.equal(isPrimaryScoreableEvidence({
status: "confirmed",
domain: remapped[1]!.domain,
datePrecision: remapped[1]!.datePrecision,
occurredFrom: remapped[1]!.occurredFrom,
occurredTo: remapped[1]!.occurredTo,
eventKind: remapped[1]!.eventKind,
}), true);
});
test("undated occupation collect still writes only occupation_note", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career",
eventKind: "career_entry" as const,
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
summary: "程序员",
}]);
assert.equal(remapped.length, 1);
assert.equal(remapped[0]?.eventKind, "occupation_note");
assert.equal(remapped[0]?.occurredFrom, null);
});
test("model career kinds other than career_entry are kept on the dated row", () => {
const remapped = applyOccupationCollectLedgerNorm(
OCCUPATION_FOCUS,
[datedOccupationInput("career_change")],
);
assert.equal(remapped[1]?.eventKind, "career_change");
});
test("unknown precision with leftover dates still writes only the note", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career",
eventKind: "career_entry" as const,
datePrecision: "unknown" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
}]);
assert.equal(remapped.length, 1);
assert.equal(remapped[0]?.occurredFrom, null);
assert.equal(remapped[0]?.datePrecision, "unknown");
});
test("occupation-norm null dates are not restored by a ?? fallback in tools", () => {
const source = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /normalized\?\.occurredFrom \?\?/);
assert.doesNotMatch(source, /normalized\?\.occurredTo \?\?/);
assert.doesNotMatch(source, /\?\? item\.occurredFrom/);
assert.doesNotMatch(source, /\?\? occurredFrom/);
assert.match(source, /occupationNormalizedLedgerRows/);
});
test("dated occupation collect raises the training gate by one career event", () => {
const education = [{
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
}, {
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
}, {
// R1(BUG-1193):补第 3 件 / 第 2 域,让职业那一件仍是开门的最后一件(4 件 3 域)。
status: "confirmed" as const,
domain: "relocation",
datePrecision: "month" as const,
occurredFrom: "2018-07-01",
occurredTo: "2018-07-31",
eventKind: "relocation",
}];
const before = trainingScoreableGate(education);
// 原值: 2 / 新值: 3(补了一件搬家) / 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193)
assert.equal(before.trainingCount, 3);
assert.equal(before.open, false);
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [datedOccupationInput()]);
const after = trainingScoreableGate([
...education,
...remapped.map((item) => ({
status: "confirmed" as const,
domain: item.domain,
datePrecision: item.datePrecision,
occurredFrom: item.occurredFrom,
occurredTo: item.occurredTo,
eventKind: item.eventKind,
})),
]);
// 原值: after.trainingCount === before.trainingCount + 1(3 件全训练)
// 新值: 训练 + holdout === before + 1,holdoutCount === 1(第 4 件起留 1 件 holdout,训练仍 3 件)
// 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193);门槛按训练 + holdout 计,职业那一件仍是开门的一件
assert.equal(after.trainingCount + after.holdoutCount, before.trainingCount + 1);
assert.equal(after.holdoutCount, 1);
assert.equal(after.trainingDomainCount, 2);
assert.equal(after.open, true);
});
test("record-evidence-batch writes note plus career_entry and rescores", async () => {
const restore = stubEngine(ENGINE_SCORE);
let collectFocusResolved = false;
try {
const quote = "2024 年 4 月开始做程序员";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [educationEvidence],
latestResult: null,
conversationSummary: conversationSummaryFixture({
activeFocus: collectFocusResolved
? null
: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: OCCUPATION_FOCUS.questionId,
expectedAnswerSchema: { collect: true, prompt: "你平时主要做什么工作?" },
}),
}),
}),
get_agentic_rectification_case_compute: () => computeFixture(),
record_agentic_rectification_evidence_batch: (_fn, args) => {
const items = Array.isArray(args.p_items) ? args.p_items as Array<Record<string, unknown>> : [];
return {
items: items.map((item, index) => ({
index,
outcome: "accepted",
evidence_id: item.event_kind === "occupation_note" ? NOTE_ID : CAREER_ID,
status: "confirmed",
idempotent: false,
clarification_fields: [],
error_code: null,
})),
accepted_count: items.length,
needs_clarification_count: 0,
rejected_count: 0,
focus_id: FOCUS_ID,
};
},
resolve_agentic_rectification_conversation_focus: () => {
collectFocusResolved = true;
return {
focus_id: FOCUS_ID,
status: "resolved",
evidence_id: NOTE_ID,
idempotent: false,
};
},
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,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
persist_agentic_rectification_candidate_v2: () => ({
...candidateSnapshotFixture({
representativeTime: "04:50",
selectionAllowed: true,
confirmationAllowed: false,
decisionReceipt: {
gates: {
event_quality: { scoreable_event_count: 3, minimum: 3 },
domain_diversity: { domains: ["education", "career"], count: 2, minimum: 2 },
},
},
}),
cached: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
userMessage: quote,
accounting: accounting.client as never,
});
const result = await (tools["rectification-record-evidence-batch"] as unknown as {
execute(input: unknown): Promise<{
accepted_count: number;
accepted_recaps: Array<{ display_date_label?: string }>;
rescore: { status: string; executed_methods: string[] };
}>;
}).execute({
caseId: CASE_ID,
focusId: FOCUS_ID,
items: [{
quote,
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2024-04",
summary: "2024年4月开始做程序员",
}],
});
const write = accounting.calls.find((call) => call.fn === "record_agentic_rectification_evidence_batch");
const items = (write?.args.p_items ?? []) as Array<Record<string, unknown>>;
assert.equal(items.length, 2);
assert.equal(items[0]?.event_kind, "occupation_note");
assert.equal(items[0]?.domain, "occupation");
assert.equal(items[0]?.date_precision, "unknown");
assert.equal(items[0]?.occurred_from, null);
assert.equal(items[1]?.event_kind, "career_entry");
assert.equal(items[1]?.domain, "career");
assert.equal(items[1]?.date_precision, "month");
assert.equal(items[1]?.occurred_from, "2024-04-01");
assert.equal(result.accepted_count, 2);
assert.equal(result.accepted_recaps.some((item) => item.display_date_label === "2024-04"), true);
assert.ok(result.rescore.executed_methods.includes("d1-rashi"));
} finally {
restore();
}
});
test("undated occupation collect batch writes only the note", async () => {
const quote = "程序员";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [educationEvidence],
latestResult: null,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: OCCUPATION_FOCUS.questionId,
expectedAnswerSchema: { collect: true, prompt: "你平时主要做什么工作?" },
}),
}),
}),
record_agentic_rectification_evidence_batch: (_fn, args) => {
const items = Array.isArray(args.p_items) ? args.p_items as Array<Record<string, unknown>> : [];
return {
items: items.map((item, index) => ({
index,
outcome: "accepted",
evidence_id: NOTE_ID,
status: "confirmed",
idempotent: false,
clarification_fields: [],
error_code: null,
})),
accepted_count: items.length,
needs_clarification_count: 0,
rejected_count: 0,
focus_id: FOCUS_ID,
};
},
resolve_agentic_rectification_conversation_focus: () => ({
focus_id: FOCUS_ID,
status: "resolved",
evidence_id: NOTE_ID,
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
userMessage: quote,
accounting: accounting.client as never,
});
await (tools["rectification-record-evidence-batch"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
focusId: FOCUS_ID,
items: [{
quote,
proposedKind: "occupation_note",
subject: "self",
domain: "occupation",
datePrecision: "unknown",
summary: "程序员",
}],
});
const write = accounting.calls.find((call) => call.fn === "record_agentic_rectification_evidence_batch");
const items = (write?.args.p_items ?? []) as Array<Record<string, unknown>>;
assert.equal(items.length, 1);
assert.equal(items[0]?.event_kind, "occupation_note");
assert.equal(items[0]?.occurred_from, null);
});