Files
Jyotisha/frontend/tests/rectification-collect-prompt.test.ts
T
Jesse_ChenandClaude Opus 5.5 a00c40d4b8 refactor(rectification): split runV9AgentTurn into prepare / attempt / retry / finish
Move-only (TASK-rectification-code-split-20260926 T3/T4). No behavior,
receipt, billing, Skill or scoring change.

- agent-run.ts: 1400 -> 135 lines; runV9AgentTurn 1046 -> 25 lines, no
  nested functions. It keeps the public types, the budget constants and the
  phase order: agent-run-prepare.ts (reliability, session, year re-ask, Skill
  identity, delivered guard, reserve, turn row / replay),
  agent-run-retry.ts (attempt loop), agent-run-attempt.ts (the old nested
  streamAttempt), agent-run-finish.ts (billing settle, receipts, interview,
  finalize), agent-run-support.ts (outcome types, retry classification, turn
  receipt writers), agent-run-messages.ts (buildAgentMessages /
  buildOpeningBrief, re-exported).
- One token changed with the move: the attempt passes its own
  `previousErrorCode` argument to buildAgentMessages instead of reading the
  enclosing `lastAttemptError`; the loop passes that same value (clears the
  old unused-parameter warning).
- Tests: whole-source contracts read tests/rectification-agent-run-surface.ts;
  behavioral runV9AgentTurn tests unchanged. agent-run growth caps added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:52:51 +08:00

162 lines
8.2 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 { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences, trimEvidenceTurnBody, trimSpokenTurnForInterview } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
const greeting = "你好,我是生时校正助手。";
const combined = `${greeting}\n\n${GENERIC_COLLECT_QUESTION}`;
assert.equal(composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION), combined);
assert.equal(composeCollectSpokenAssistantText(combined, GENERIC_COLLECT_QUESTION), combined);
assert.equal(composeCollectSpokenAssistantText("", GENERIC_COLLECT_QUESTION), GENERIC_COLLECT_QUESTION);
assert.equal(composeCollectSpokenAssistantText(GENERIC_COLLECT_QUESTION, GENERIC_COLLECT_QUESTION), GENERIC_COLLECT_QUESTION);
assert.equal(composeCollectSpokenAssistantText(greeting, " "), greeting);
assert.equal(
composeCollectSpokenAssistantText(` ${greeting} `, ` ${GENERIC_COLLECT_QUESTION} `),
combined,
);
});
test("detachCollectSpokenAssistantText removes only an exact trailing stem", () => {
const greeting = "你好,我是生时校正助手。";
const combined = `${greeting}\n\n${GENERIC_COLLECT_QUESTION}`;
assert.equal(detachCollectSpokenAssistantText(combined, GENERIC_COLLECT_QUESTION), greeting);
assert.equal(detachCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION), greeting);
assert.equal(detachCollectSpokenAssistantText(GENERIC_COLLECT_QUESTION, GENERIC_COLLECT_QUESTION), GENERIC_COLLECT_QUESTION);
assert.equal(detachCollectSpokenAssistantText("", GENERIC_COLLECT_QUESTION), "");
});
test("GET rebuild detaches a legacy composed suffix only when asked_turn_id matches", () => {
const greeting = "你好,我是生时校正助手。";
const combined = composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION);
const turns = [{
id: TURN_ID,
role: "assistant" as const,
text: combined,
status: "completed",
}];
const linked = attachQuestionsToTurns(turns, [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:unknown:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: null,
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
}]);
assert.equal(linked[0]?.text, greeting);
assert.equal(linked[0]?.question?.prompt, GENERIC_COLLECT_QUESTION);
const unlinked = attachQuestionsToTurns(turns, [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:unknown:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: null,
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: null,
}]);
// 原值: askedTurnId=null 的口述焦点不挂消息,question 为 null,正文保留拼接题干
// 新值: 无 askedTurnId 的活动口述焦点也挂到最后一条助手消息,正文剥掉题干
// 原因: BUG-678 挂回规则对口述题与选择题一视同仁
assert.equal(unlinked[0]?.text, greeting);
assert.equal(unlinked[0]?.question?.kind, "collect_spoken");
assert.equal(unlinked[0]?.question?.prompt, GENERIC_COLLECT_QUESTION);
});
test("composeCollectSpokenAssistantText drops a near-duplicate restatement of the stem", () => {
const stem = USER_COLLECT_QUESTION.education;
const restated = `${stem.slice(0, 12)}还记得大概哪一年吗?`;
const body = `这条记下了。${restated}`;
const composed = composeCollectSpokenAssistantText(body, stem);
assert.equal(composed.includes(restated), false);
assert.equal(composed.split(stem).length - 1, 1);
assert.ok(composed.endsWith(stem));
});
test("runtime no longer composes the stem into assistant_message", () => {
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const agentRun = rectificationAgentRunSurface;
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
// 原值: 无 askedTurnId 的活动选择题才挂回
// 新值: 无 askedTurnId 的活动焦点(含口述题)都挂回
// 原因: BUG-678 口述年份追问也要出现在助手消息里
assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences(body, stem), "范围已经收到,收在 05:00–05:10。");
});
test("stripQuestionSentences drops a mid-body stem and keeps the surrounding sentences", () => {
const stem = "你大概是哪一年搬的家?";
const body = "记下了。你大概是哪一年搬的家?范围还在 05:00–05:10。";
assert.equal(stripQuestionSentences(body, stem), "记下了。范围还在 05:00–05:10。");
});
test("stripQuestionSentences returns empty when the body is only a question", () => {
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences("你大概哪一年搬过家?", stem), "");
assert.equal(RECTIFICATION_USER_COPY.collectHandoff.includes("请回答下面的问题"), false);
});
test("attachQuestionsToTurns leaves body unchanged when the turn has no focus", () => {
const body = "范围收到 05:00–05:10。你大概哪一年搬过家?";
const turns = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant" as const,
text: body,
}], []);
assert.equal(turns[0]?.text, body);
assert.equal(turns[0]?.question, null);
});
test("trimEvidenceTurnBody keeps the first sentence and strips value judgments", () => {
const three = trimEvidenceTurnBody("记下了:2016 年 9 月入学。这对校正很有帮助。接下来我们继续。");
assert.equal(three, "记下了:2016 年 9 月入学。");
assert.doesNotMatch(three, /很有帮助|很有价值|很有分量|特别有用/);
const judged = trimEvidenceTurnBody("记下了:2020 年 6 月毕业,特别有用。");
assert.doesNotMatch(judged, /特别有用/);
const two = trimEvidenceTurnBody("记下了:2016 年 9 月入学。\n\n范围收到 04:50–05:10。");
assert.equal(two, "记下了:2016 年 9 月入学。\n\n范围收到 04:50–05:10。");
});
test("trimSpokenTurnForInterview keeps three delivery sentences and one evidence sentence", () => {
const four = [
"目前范围 04:49–04:53。",
"对照了 8 件经历,事件吻合率 80%。",
"这只是代表性候选,不是已确认的唯一出生分钟。",
"还可以再看一眼分盘。",
].join("");
const delivery = trimSpokenTurnForInterview(four, true);
assert.equal(delivery.split(/(?<=。)/).filter(Boolean).length, 3);
assert.match(delivery, /目前范围 04:49–04:53/);
assert.match(delivery, /对照了 8 件经历/);
assert.doesNotMatch(delivery, /还可以再看一眼分盘/);
const evidence = trimSpokenTurnForInterview(
"记下了:2016 年 9 月入学。这对校正很有帮助。接下来我们继续。",
false,
);
assert.equal(evidence, "记下了:2016 年 9 月入学。");
});