Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
187 lines
12 KiB
TypeScript
187 lines
12 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 { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
|
import { 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, "req-1:adopt");
|
|
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");
|
|
});
|