Move-only (TASK-rectification-code-split-20260926 T2/T4). No behavior, API, copy, DB, Skill, billing or scoring change. - route.ts: 1077 -> 152 lines; POST 927 -> 114. It builds the Supabase clients (so the setup-failure mapping stays here) and assembles: agent-route-request.ts (auth / product / schema / flag / Case-Session binding), agent-route-typed-message.ts (declared-window reply, typed-answer preflight, unfocused classification), agent-route-structured-choice.ts, agent-route-agent-turn.ts (opening / read-only / typed agent stream and its exit gate), agent-route-billing.ts, agent-route-support.ts (schema, one-shot NDJSON reply, context types). - The four mutable preflight lets (expectedWrite, collectIntent, writeClassified, classifierDiagnostic) that crossed branches are one turnState object; values and flow unchanged. - Tests: whole-source contracts read tests/rectification-agent-route-surface.ts; the typed fast-path slice is rebuilt from the new files; billing, declared-window and structured-choice slices now call the handlers (billing in a child process because feature-pricing imports server-only), each with 原值/新值/原因. Route growth caps added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
deliveryNarrationAllowed,
|
||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||
import {
|
||
USER_COLLECT_QUESTION,
|
||
USER_COLLECT_QUESTION_RETRY,
|
||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
import { exhaustionSpokenCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import {
|
||
COLLECT_FOCUS_RETRY_SUFFIXES,
|
||
collectFocusRetryQuestionIds,
|
||
persistServerOwnedFocus,
|
||
stableFollowupQuestionId,
|
||
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import {
|
||
CASE_ID,
|
||
TURN_ID,
|
||
USER_ID,
|
||
candidateSnapshotFixture,
|
||
computeFixture,
|
||
dossierFixture,
|
||
fakeAccounting,
|
||
receiptHandlers,
|
||
} from "./rectification-v9-test-support.ts";
|
||
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
|
||
|
||
const BASE_QUESTION = "collect:relationship:collect_method_evidence";
|
||
|
||
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
|
||
return {
|
||
method_id: "dasha_events",
|
||
intent: "collect_method_evidence",
|
||
ask_theme: "dated_event",
|
||
domain: "relationship",
|
||
kind_hint: null,
|
||
user_prompt_hint: "collect",
|
||
must_not_label: false,
|
||
choice_frame: null,
|
||
source: "method_coverage",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function focusRowFromArgs(args: Record<string, unknown>) {
|
||
return {
|
||
id: "abababab-abab-4bab-8bab-abababababab",
|
||
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-10T00:00:00.000Z",
|
||
resolved_at: null,
|
||
idempotent: false,
|
||
};
|
||
}
|
||
|
||
function datedEvidence() {
|
||
return [
|
||
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
|
||
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" },
|
||
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
|
||
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" },
|
||
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2022-06-01", occurredTo: null, eventKind: "relocation" },
|
||
{ id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2021-11-01", occurredTo: null, eventKind: "health_episode" },
|
||
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
|
||
] as const;
|
||
}
|
||
|
||
function rpcEvidence(item: ReturnType<typeof datedEvidence>[number]) {
|
||
return {
|
||
id: item.id,
|
||
source_turn_id: TURN_ID,
|
||
subject: "self",
|
||
event_kind: item.eventKind,
|
||
domain: item.domain,
|
||
occurred_from: item.occurredFrom,
|
||
occurred_to: item.occurredTo,
|
||
date_precision: item.datePrecision,
|
||
summary: item.domain,
|
||
status: item.status,
|
||
supersedes_evidence_id: null,
|
||
created_at: "2026-09-10T00:00:00.000Z",
|
||
};
|
||
}
|
||
|
||
function relationshipOpenDossier() {
|
||
const ledger = datedEvidence();
|
||
const evidence = ledger.map((item) => rpcEvidence(item));
|
||
return dossierFixture({
|
||
evidence,
|
||
evidenceCount: evidence.length,
|
||
latestResult: candidateSnapshotFixture({
|
||
selectionAllowed: true,
|
||
representativeTime: "04:51",
|
||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(ledger as never),
|
||
decisionReceipt: {
|
||
acceptance_allowed: true,
|
||
selection_allowed: true,
|
||
propose_allowed: true,
|
||
},
|
||
}),
|
||
conversationSummary: {
|
||
confirmed_evidence_summary: [],
|
||
pending_revisions: [],
|
||
active_focus: null,
|
||
declined_skipped_topics: [],
|
||
candidate_divergence_summary: null,
|
||
missing_evidence_categories: [],
|
||
last_result_policy: null,
|
||
summary_version: 1,
|
||
updated_at: "2026-09-10T00:00:00.000Z",
|
||
},
|
||
});
|
||
}
|
||
|
||
test("collect focus retries next then next2 then next3 before duplicate_focus", async () => {
|
||
assert.deepEqual(COLLECT_FOCUS_RETRY_SUFFIXES, ["next", "next2", "next3"]);
|
||
assert.deepEqual(
|
||
collectFocusRetryQuestionIds(BASE_QUESTION),
|
||
[`${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`, `${BASE_QUESTION}:next3`],
|
||
);
|
||
const blocked = new Set([BASE_QUESTION, `${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`]);
|
||
let writes = 0;
|
||
const accounting = fakeAccounting({
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
writes += 1;
|
||
const id = String(args.p_question_id);
|
||
if (blocked.has(id)) throw new Error("agentic_rectification_focus_idempotency_conflict");
|
||
return focusRowFromArgs(args);
|
||
},
|
||
});
|
||
const persisted = await persistServerOwnedFocus({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
activeFocus: null,
|
||
decisionReceipt: null,
|
||
followup: collectFollowup(),
|
||
});
|
||
assert.equal(persisted.status, "created");
|
||
assert.equal(writes, 4);
|
||
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
|
||
assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship);
|
||
});
|
||
|
||
test("collect focus four collisions return duplicate_focus", async () => {
|
||
let writes = 0;
|
||
const accounting = fakeAccounting({
|
||
set_agentic_rectification_conversation_focus: () => {
|
||
writes += 1;
|
||
throw new Error("agentic_rectification_focus_idempotency_conflict");
|
||
},
|
||
});
|
||
const persisted = await persistServerOwnedFocus({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
activeFocus: null,
|
||
decisionReceipt: null,
|
||
followup: collectFollowup(),
|
||
});
|
||
assert.equal(persisted.status, "duplicate_focus");
|
||
assert.equal(writes, 4);
|
||
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
|
||
});
|
||
|
||
test("collect_retry only changes copy, not whether a suffix is tried", async () => {
|
||
const accounting = fakeAccounting({
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
if (args.p_question_id === BASE_QUESTION) {
|
||
throw new Error("agentic_rectification_focus_idempotency_conflict");
|
||
}
|
||
return focusRowFromArgs(args);
|
||
},
|
||
});
|
||
const persisted = await persistServerOwnedFocus({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
activeFocus: null,
|
||
decisionReceipt: null,
|
||
followup: collectFollowup({ collect_retry: true }),
|
||
});
|
||
assert.equal(persisted.status, "created");
|
||
assert.equal(persisted.questionId, `${BASE_QUESTION}:next`);
|
||
assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
|
||
});
|
||
|
||
test("probe question ids do not receive collect suffixes", () => {
|
||
const followup: MethodFollowup = {
|
||
method_id: "dasha_events",
|
||
intent: "distinguish_candidates",
|
||
ask_theme: "dated_event",
|
||
domain: "education",
|
||
kind_hint: null,
|
||
user_prompt_hint: "ask",
|
||
must_not_label: false,
|
||
choice_frame: null,
|
||
source: "event_probe",
|
||
semantic_key: "education:2016",
|
||
candidate_split_hash: "education:2016",
|
||
};
|
||
const questionId = stableFollowupQuestionId(followup);
|
||
assert.match(questionId, /^probe:/);
|
||
assert.equal(questionId.includes(":next"), false);
|
||
});
|
||
|
||
test("deliveryNarrationAllowed requires public can_adopt or a selectable range offer", () => {
|
||
assert.equal(deliveryNarrationAllowed({
|
||
canAdopt: true,
|
||
sessionOutcome: "collect_evidence",
|
||
nextAction: "ask_fact_collection",
|
||
selectionAllowed: true,
|
||
}), false);
|
||
assert.equal(deliveryNarrationAllowed({
|
||
canAdopt: true,
|
||
sessionOutcome: "adopt_representative",
|
||
nextAction: "ready_to_adopt",
|
||
selectionAllowed: true,
|
||
}), true);
|
||
assert.equal(deliveryNarrationAllowed({
|
||
canAdopt: false,
|
||
sessionOutcome: "provisional_range",
|
||
nextAction: "offer_provisional_range",
|
||
selectionAllowed: true,
|
||
}), true);
|
||
assert.equal(deliveryNarrationAllowed({
|
||
canAdopt: false,
|
||
sessionOutcome: "collect_evidence",
|
||
nextAction: "offer_provisional_range",
|
||
selectionAllowed: false,
|
||
}), false);
|
||
// 原值: completed_with_range 且 canAdopt=false 不能播交付
|
||
// 新值: selectionAllowed 且 outcome 在 DELIVERY_OUTCOMES 即可播
|
||
// 原因: BUG-681 能说就能画,completed_with_range / provisional_range 与卡片同真同假
|
||
assert.equal(deliveryNarrationAllowed({
|
||
canAdopt: false,
|
||
sessionOutcome: "completed_with_range",
|
||
nextAction: "complete_with_range",
|
||
selectionAllowed: true,
|
||
}), true);
|
||
});
|
||
|
||
test("duplicate_focus on the last collect ask does not emit delivery copy", async () => {
|
||
const raw = relationshipOpenDossier();
|
||
const remaining = exhaustionSpokenCollectFollowup({
|
||
evidence: datedEvidence(),
|
||
declinedTopics: [],
|
||
});
|
||
// 原值: leftover 感情采集
|
||
// 新值: 训练门开后 leftover dated collect 为 null
|
||
// 原因: BUG-648
|
||
assert.equal(remaining, null);
|
||
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => raw,
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||
focus: {
|
||
id: "11111111-1111-4111-8111-111111111111",
|
||
case_id: CASE_ID,
|
||
question_id: args.p_question_id,
|
||
intent: args.p_intent,
|
||
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-10T00:00:00.000Z",
|
||
resolved_at: null,
|
||
},
|
||
idempotent: false,
|
||
}),
|
||
});
|
||
const idle = await persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.doesNotMatch(idle.hostNarration ?? "", /领域不限|做不了|材料不够/);
|
||
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
||
// 原值: p_target_domain 不得是 relationship / finance
|
||
// 新值: 写的是定向感情线(本夹具就叫 relationshipOpenDossier,这条线是开着的)
|
||
// 原因: D5——七条线全部轮到,感情线仍未覆盖就该被问;本题真正的断言是
|
||
// 上一行「不得播交付文案」(BUG-751)
|
||
assert.match(String(setFocus?.args.p_question_id ?? ""), /^collect:(targeted|guided):/);
|
||
assert.notEqual(setFocus?.args.p_target_domain, "finance");
|
||
});
|
||
|
||
test("delivery copy is gated at the three persist sites", () => {
|
||
const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
|
||
const route = rectificationAgentRouteSurface;
|
||
const decision = readFileSync(
|
||
new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
assert.match(decision, /export function deliveryNarrationAllowed/);
|
||
assert.match(answer, /deliveryNarrationAllowed\(decision, nextAction\.type\)/);
|
||
assert.match(answer, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
|
||
assert.doesNotMatch(answer, /this ask is already closed; fall through to adopt/);
|
||
assert.match(route, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
|
||
});
|