Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
229 lines
15 KiB
TypeScript
229 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
|
import { expectedAnswerSchemaFor, persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
|
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
|
|
import { adoptTurnRequestId, exhaustionGateRequestId, inferenceForPersistedAnswer, writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import { composeChoiceNarration } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
|
import { alreadyDelivered, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
|
|
import { deliveryTurnNarration, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
|
|
import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts";
|
|
import { fakeAccounting } from "./rectification-v9-test-support.ts";
|
|
|
|
// Real device 2026-10-01 (BUG-1146): after 采用 the first post-adopt check persisted, but the
|
|
// case GET rebuilt no card, so the stem showed without buttons and refresh did not help. Fixtures
|
|
// that left activeFocus.questionId unset never reached the question-id comparison.
|
|
const EVIDENCE = [{
|
|
status: "confirmed", domain: "career", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null,
|
|
}, {
|
|
status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null,
|
|
eventKind: "education_start", summary: "fictional enrolment",
|
|
}];
|
|
|
|
const DATED_CHECK: DiscriminatingEventProbe = {
|
|
year: 2018, year_label: "2018 年前后", domain: "career", event_family: "入职、升职或职责明显加重",
|
|
source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false,
|
|
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。", role: "reverse_verify",
|
|
semantic_key: "career.2018.dasha_activation", candidate_split_hash: "career.2018.dasha_activation",
|
|
candidate_ids: ["05:00", "05:04"],
|
|
expected_outcomes: [
|
|
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:04"] },
|
|
{ answer_class: "no", supports: ["05:04"], conflicts: ["05:00"] },
|
|
{ answer_class: "unsure", supports: [], conflicts: [] },
|
|
],
|
|
} as never;
|
|
|
|
const QUALITY_CHECK: DiscriminatingEventProbe = {
|
|
year: 2016, year_label: "2016 年 9 月", domain: "education", event_family: "学业或考试发挥失常、压力特别大",
|
|
source: "known_event_quality", role: "distinguish", choice_kind: "event_quality",
|
|
tracks: ["vimshottari"], tracks_agree: true, unique_minute_claim: false,
|
|
user_meaning: "2016 年 9 月那次升学,结果更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。",
|
|
semantic_key: "education.2016.09.known_event_quality", candidate_split_hash: "education.2016.09.known_event_quality",
|
|
target_evidence_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
candidate_ids: ["05:00", "05:04"],
|
|
expected_outcomes: [
|
|
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:04"] },
|
|
{ answer_class: "no", supports: ["05:04"], conflicts: ["05:00"] },
|
|
{ answer_class: "unsure", supports: [], conflicts: [] },
|
|
],
|
|
} as never;
|
|
|
|
for (const [label, probe] of [["dated", DATED_CHECK], ["quality", QUALITY_CHECK]] as const) {
|
|
test(`post-adopt ${label} check: the card persisted after 采用 is rebuilt on GET`, async () => {
|
|
const input = {
|
|
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative" as const,
|
|
selectionAllowed: true, proposeAllowed: true, eventProbes: [probe],
|
|
};
|
|
const plan = buildMethodFollowupPlan(input as never);
|
|
const followup = plan.next_followup;
|
|
assert.ok(followup, `the ${label} probe yields a post-adopt check`);
|
|
assert.equal(followup.intent, "reverse_verify");
|
|
const accounting = fakeAccounting({
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
id: "abababab-abab-4bab-8bab-abababababab", case_id: "x", question_id: args.p_question_id, intent: args.p_intent,
|
|
target_evidence_id: null, 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-10-01T00:00:00.000Z", resolved_at: null, idempotent: false,
|
|
}),
|
|
});
|
|
const persisted = await persistServerOwnedFocus({
|
|
accounting: accounting.client, userId: "11111111-1111-4111-8111-111111111111",
|
|
caseId: "22222222-2222-4222-8222-222222222222", activeFocus: null, decisionReceipt: null, followup,
|
|
});
|
|
assert.equal(persisted.status, "created");
|
|
assert.ok(persisted.focus?.questionId, "a real focus always carries a question id");
|
|
const card = projectRectificationChoiceCard({ ...input, activeFocus: persisted.focus } as never);
|
|
assert.ok(card, `refresh rebuilds the ${label} post-adopt card (question id ${persisted.focus!.questionId})`);
|
|
assert.equal(card!.options.length, 4);
|
|
});
|
|
}
|
|
|
|
const USER = "11111111-1111-4111-8111-111111111111";
|
|
const CASE = "22222222-2222-4222-8222-222222222222";
|
|
const RESULT = "33333333-3333-4333-8333-333333333333";
|
|
|
|
test("采用 writes its own turn, hangs the post-adopt check on it and quiets the read_only run", async () => {
|
|
resetDeliveryTurnGuardForTests();
|
|
const accounting = fakeAccounting({
|
|
append_agentic_rectification_turn: (_fn, args) => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false, args }),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
id: "abababab-abab-4bab-8bab-abababababab", case_id: CASE, question_id: args.p_question_id, intent: args.p_intent,
|
|
target_evidence_id: null, target_domain: args.p_target_domain, target_kind: args.p_target_kind,
|
|
expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_turn_id: args.p_asked_turn_id,
|
|
asked_at: "2026-10-01T00:00:00.000Z", resolved_at: null, idempotent: true,
|
|
}),
|
|
});
|
|
const focus = {
|
|
id: "abababab-abab-4bab-8bab-abababababab", questionId: "probe:career.2018.dasha_activation", intent: "reverse_verify",
|
|
targetDomain: "career", targetKind: "event", targetEvidenceId: null, expectedAnswerSchema: { semantic_key: "career.2018.dasha_activation" },
|
|
status: "active", askedTurnId: null,
|
|
};
|
|
const out = await writeAdoptFollowupTurn({
|
|
accounting: accounting.client, userId: USER, caseId: CASE, requestId: "req-1", resultId: RESULT,
|
|
hostNarration: "2018 年前后,有没有入职或职责明显加重?", focus: focus as never, savedTime: "05:00:00",
|
|
});
|
|
assert.deepEqual(out, { turnId: "44444444-4444-4444-8444-444444444444" });
|
|
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
|
assert.equal(turn?.args.p_request_id, adoptTurnRequestId("req-1"));
|
|
assert.equal(turn?.args.p_user_message, null);
|
|
assert.equal(turn?.args.p_assistant_message, "已采用 05:00。再核对一件过去的事。2018 年前后,有没有入职或职责明显加重?");
|
|
const link = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
|
assert.equal(link?.args.p_asked_turn_id, "44444444-4444-4444-8444-444444444444");
|
|
assert.equal(alreadyDelivered({ caseId: CASE, resultId: RESULT, hasUserMessage: false }), true);
|
|
resetDeliveryTurnGuardForTests();
|
|
});
|
|
|
|
test("采用 with nothing to say writes no turn", async () => {
|
|
const accounting = fakeAccounting({});
|
|
const out = await writeAdoptFollowupTurn({
|
|
accounting: accounting.client, userId: USER, caseId: CASE, requestId: "req-2", resultId: RESULT,
|
|
hostNarration: " ", focus: null,
|
|
});
|
|
assert.equal(out, null);
|
|
assert.equal(accounting.calls.length, 0);
|
|
});
|
|
|
|
// BUG-1147: in the segment (盘型) product the bubble neither reads a minute leaderboard nor
|
|
// repeats the boundary line the card already prints.
|
|
test("segment product: choice ack has no minute leaderboard", () => {
|
|
const base = {
|
|
optionId: "A" as const, scoring: true, appliedInference: true, answerClass: "yes" as const,
|
|
credibleBefore: ["04:52", "05:05"] as const, credibleAfter: ["04:52", "05:05"] as const,
|
|
deltasByCluster: [
|
|
{ range: ["04:52", "04:53"], delta: 0.4 }, { range: ["05:00", "05:05"], delta: -0.4 },
|
|
] as never,
|
|
};
|
|
const minute = composeChoiceNarration(base as never);
|
|
const segment = composeChoiceNarration({ ...base, segmentProduct: true } as never);
|
|
assert.equal(segment, "已记录,范围没变。");
|
|
assert.doesNotMatch(segment, /领先|落后/);
|
|
assert.match(minute, /领先/, "the minute product still reads the movement");
|
|
});
|
|
|
|
test("segment product: delivery bubble leaves the boundary line to the card", () => {
|
|
const summary: SegmentSummary = {
|
|
targets: ["D1", "D9"], window_minutes: 21, scan_complete: true, no_rectification_needed: false, adoption_minute: null,
|
|
charts: [
|
|
{ chart: "D1", sign: 3, tier: "certain", alternatives: [], tied: false },
|
|
{ chart: "D9", sign: 7, tier: "credible", alternatives: [], tied: false },
|
|
],
|
|
} as never;
|
|
const segment = deliveryTurnNarration({ credibleRange: ["04:52", "05:05"], segmentSummary: summary });
|
|
assert.ok(!segment.includes(REPRESENTATIVE_MINUTE_DISCLAIMER));
|
|
assert.match(segment, /D9 上升:天蝎(较可信)/);
|
|
const minute = deliveryTurnNarration({ credibleRange: ["04:52", "05:05"] });
|
|
assert.ok(minute.endsWith(REPRESENTATIVE_MINUTE_DISCLAIMER));
|
|
});
|
|
|
|
test("client: an adopt turn from the accept POST replaces the live row; no read_only run", async () => {
|
|
const { chatRunRecorder, offerableCandidateResult, jsonResponse, runRectificationCandidateAccept, withFakeFetch } = await import("./rectification-chat-run-fixtures.ts");
|
|
const recorder = chatRunRecorder([{ role: "assistant", text: "已记录,范围没变。", renderKey: "persisted-prev-0", state: "settled", turnId: "prev" } as never]);
|
|
recorder.candidateResult.set(offerableCandidateResult());
|
|
const adoptTurn = { id: "adopt-turn", role: "assistant", text: "已按 04:53 排盘。再核对一件事。", status: "completed" };
|
|
await withFakeFetch(() => jsonResponse({
|
|
ok: true, status: "accepted", saved_time: "04:53:00", result_id: "result-1", idempotent: false, adopt_turn_id: "adopt-turn",
|
|
}), async () => {
|
|
await runRectificationCandidateAccept(recorder.acceptDeps({
|
|
loadCaseSnapshot: async (merge) => { merge?.([{ id: "prev", role: "assistant", text: "已记录,范围没变。", status: "completed" }, adoptTurn]); return undefined; },
|
|
}), "88888888-8888-4888-8888-888888888881");
|
|
});
|
|
assert.equal(recorder.error.get(), "");
|
|
assert.equal(recorder.sends.length, 0, "no read_only run after the server wrote the adopt turn");
|
|
const transcript = recorder.messages.get();
|
|
assert.deepEqual(transcript.map((message) => message.turnId), ["prev", "adopt-turn"]);
|
|
assert.ok(transcript.every((message) => message.state === "settled"), "the live row is gone");
|
|
});
|
|
|
|
test("client: no adopt turn (idempotent accept) keeps the read_only continuation", async () => {
|
|
const { chatRunRecorder, offerableCandidateResult, jsonResponse, runRectificationCandidateAccept, withFakeFetch } = await import("./rectification-chat-run-fixtures.ts");
|
|
const recorder = chatRunRecorder();
|
|
recorder.candidateResult.set(offerableCandidateResult());
|
|
await withFakeFetch(() => jsonResponse({ ok: true, status: "accepted", saved_time: "04:53:00", result_id: "result-1", idempotent: true }),
|
|
async () => { await runRectificationCandidateAccept(recorder.acceptDeps(), "88888888-8888-4888-8888-888888888881"); });
|
|
assert.equal(recorder.error.get(), "");
|
|
assert.equal(recorder.sends.length, 1);
|
|
assert.equal(recorder.sends[0]!.action, "read_only");
|
|
});
|
|
|
|
// BUG-1149: real PostgreSQL types p_request_id as uuid; 「<id>:adopt」/「<id>:gate」 raised 22P02,
|
|
// the accept route swallowed it, and the gate narration never persisted.
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
test("deterministic turn request ids are uuids and stable per seed", () => {
|
|
const adopt = adoptTurnRequestId("66666666-6666-4666-8666-666666666666");
|
|
assert.match(adopt, UUID);
|
|
assert.equal(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666"));
|
|
assert.notEqual(adopt, adoptTurnRequestId("77777777-7777-4777-8777-777777777777"));
|
|
const gate = exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE);
|
|
assert.match(gate, UUID);
|
|
assert.equal(gate, exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE));
|
|
assert.match(exhaustionGateRequestId(null, CASE), UUID);
|
|
assert.notEqual(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666:x"));
|
|
});
|
|
|
|
// BUG-1150: post-adopt checks were stamped 「probe:<key>」 while the state holds the split-hashed
|
|
// 「probe:<key>:<split>」; the transition RPC rejected every answer as stale_probe.
|
|
test("a scoring post-adopt check carries the state's probe id", () => {
|
|
const plan = buildMethodFollowupPlan({
|
|
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative",
|
|
selectionAllowed: true, proposeAllowed: true, eventProbes: [DATED_CHECK],
|
|
} as never);
|
|
const followup = plan.next_followup!;
|
|
assert.equal(followup.intent, "reverse_verify");
|
|
const stateId = "probe:career.2018.dasha_activation:career.2018.dasha_activation";
|
|
const inference = {
|
|
algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:58-05:06:05:00,05:04", revision: 6,
|
|
phase: "discrimination", result_status: "discriminating", range_start: "04:58", range_end: "05:06",
|
|
candidates: ["05:00", "05:04"].map((time, index) => ({ id: time, time, cluster_range: ["04:58", "05:06"], prior_score: 10,
|
|
posterior_score: 10, probability: 0.5, status: "active", rank: index + 1, strong_conflict_count: 0 })),
|
|
events: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "05:00", credible_range: ["04:58", "05:06"],
|
|
probes: [{ ...DATED_CHECK, id: stateId, question: "2018", information_gain: 0.5, year: 2018 }],
|
|
};
|
|
const receipt = { inference_state: inference };
|
|
assert.ok(inferenceForPersistedAnswer(receipt)?.probes.some((probe) => probe.id === stateId), "fixture state parses");
|
|
const schema = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, receipt, followup);
|
|
assert.equal(schema?.probe_id, stateId);
|
|
const bare = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, null, followup);
|
|
assert.equal(bare?.probe_id, "probe:career.2018.dasha_activation", "no state: the named key, as before");
|
|
});
|