Files
Jyotisha/frontend/tests/rectification-opening-plain-20260926.test.ts
T
jesse-uxandGrok (agent loop) 903d206f3b State the rectification opening limit.
The fixed opening and the model brief now say the step does not guarantee a single minute, and an opening that skips that limit falls back. Assertion updates record old value, new value, and why. Ran the opening copy tests: 0 fail. tsc --noEmit exit 0. npm run lint 0 errors.

Co-Authored-By: Grok (agent loop)
2026-10-27 03:52:13 +08:00

323 lines
17 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.
/**
* 2026-09-26, staging e53052a2, real device (TASK-rectification-opening-plain-20260926):
*
* BUG-1049 (recurrence of BUG-504): the opening showed no examples. BUG-604
* moved the six examples out of the stem into the body's third sentence;
* BUG-648 then rewrote that sentence to begin with the stem's first 12
* characters, and `stripQuestionSentences` deleted any body sentence sharing
* that prefix — on the server, on GET, and (since BUG-1045) in the client
* merge. The body also spoke jargon (大运、盘面、代表分钟、精确到秒).
* BUG-1050: the step receipt spoke internal names (「读取校正记录」「设置对话焦点」)
* and listed the second set-focus call again.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
GENERIC_COLLECT_QUESTION,
OPENING_ANSWER_EXAMPLE,
OPENING_BODY_JARGON,
OPENING_COLLECT_DOMAINS,
isAcceptableOpeningBody,
openingSpokenBody,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
composeCollectSpokenAssistantText,
stripQuestionSentences,
} from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import {
buildMethodFollowupPlan,
isOpeningCollectFollowup,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts";
import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
} from "../src/lib/rectification-activity-labels.ts";
import {
completeActivityTraceStep,
emptyActivityTrace,
startActivityTraceStep,
} from "../src/lib/agent-activity-trace.ts";
import { rectificationTimelineRows } from "../src/lib/rectification-timeline-adapter.ts";
import { timelineSummaryLabel } from "../src/components/consultation-run-timeline.tsx";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { productDeliveryForDomain } from "../src/lib/rectification-agentic/v9/product-delivery.ts";
const RANGE = ["04:45", "05:15"] as const;
const BODY = openingSpokenBody(RANGE);
const BODY_SENTENCES = [
"我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。",
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
"这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。",
"中途想停也可以,照你原来填的时间用。",
];
const EXAMPLES = ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"];
function occurrences(text: string, needle: string): number {
return text.split(needle).length - 1;
}
function openingFocus(askedTurnId: string | null): ConversationFocus {
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "other",
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-26T08:00:00.000Z",
resolvedAt: null,
askedTurnId,
answerOption: null,
};
}
test("(a) the zero-evidence opening question slot carries 比如 + six examples + 例如 an example answer", () => {
const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup;
assert.ok(followup);
assert.equal(isOpeningCollectFollowup(followup, []), true);
const stem = spokenFollowupForUser(followup, []) ?? "";
assert.equal(stem, GENERIC_COLLECT_QUESTION);
assert.match(stem, /比如/);
assert.match(stem, /例如/);
for (const example of EXAMPLES) assert.ok(stem.includes(example), example);
assert.deepEqual([...OPENING_COLLECT_DOMAINS], EXAMPLES);
assert.ok(stem.includes(`「${OPENING_ANSWER_EXAMPLE}」`));
// The example answer is the only year in the stem and it is fixed copy, not
// derived from a birth date; the body stays year-free.
assert.deepEqual(stem.match(/(?:19|20)\d{2}/g), ["2015"]);
assert.doesNotMatch(BODY, /(?:19|20)\d{2}/);
});
test("(a) only the zero-evidence opening uses the example-bearing stem; once an event is dated it is gone", () => {
const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup;
assert.ok(followup);
const dated = [{
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2019-03-01",
occurredTo: null,
}];
assert.equal(isOpeningCollectFollowup(followup, dated), false);
assert.equal(isOpeningCollectFollowup({ ...followup, collect_retry: true }, []), false);
assert.notEqual(spokenFollowupForUser({ ...followup, collect_retry: true }, []), GENERIC_COLLECT_QUESTION);
});
test("(a) the opening set-focus stores the server stem even when the model rewords spokenPrompt", async () => {
const writes: Array<Record<string, unknown>> = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [],
evidenceCount: 0,
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-26T00:00:00.000Z",
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes.push(args);
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
case_id: CASE_ID,
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-09-26T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id,
idempotent: false,
};
},
});
const tool = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
})["rectification-set-focus"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> };
const result = await tool.execute({
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
// A valid rewording without examples — what the model tends to write.
spokenPrompt: "先说你最容易想起的一两件,年月大概就行。",
});
assert.equal((result as { error?: string }).error, undefined);
assert.equal(writes.length, 1);
const schema = writes[0]?.p_expected_answer_schema as Record<string, unknown>;
assert.equal(schema.prompt, GENERIC_COLLECT_QUESTION);
});
test("(b) server compose, GET attach and client merge keep both body sentences and show the stem once", () => {
// Server: a deterministic reply composes body + stem (BUG-969 ③).
const composed = composeCollectSpokenAssistantText(BODY, GENERIC_COLLECT_QUESTION);
assert.equal(occurrences(composed, GENERIC_COLLECT_QUESTION), 1);
for (const sentence of BODY_SENTENCES) assert.ok(composed.includes(sentence), sentence);
// Idempotent.
assert.equal(composeCollectSpokenAssistantText(composed, GENERIC_COLLECT_QUESTION), composed);
for (const stored of [BODY, composed]) {
// GET: the question is attached and the stem leaves the bubble.
const [turn] = attachQuestionsToTurns([
{ id: "t0", role: "assistant" as const, text: stored, status: "completed" },
], [openingFocus("t0")]);
assert.ok(turn);
assert.equal(turn.question?.prompt, GENERIC_COLLECT_QUESTION);
assert.equal(turn.text, BODY, "GET keeps the body whole");
const readerSees = `${turn.text}\n${turn.question?.prompt}`;
assert.equal(occurrences(readerSees, GENERIC_COLLECT_QUESTION), 1);
for (const example of EXAMPLES) assert.equal(occurrences(readerSees, example), 1, example);
// Client: the live bubble merged with the snapshot turn reads the same.
const live: SnapshotTurnMessage[] = [{ role: "assistant", turnId: "t0", text: stored, renderKey: "live" }];
const [merged] = mergeTurnQuestions(live, [turn]);
assert.equal(merged?.text, BODY, "client merge keeps the body whole");
assert.equal(merged?.question?.prompt, GENERIC_COLLECT_QUESTION);
}
});
test("(b) a body sentence that only shares an opening phrase with the stem is kept (the BUG-1049 deletion)", () => {
// Exactly what staging e53052a2 stored for the opening (Skill 10.0.30 copy).
const oldStem = "先说你最容易想起的一两件,年月大概就行。";
const oldExamples = "先说你最容易想起的一两件,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁或长辈住院、生病受伤,年月大概就行。";
const oldBody = `眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。${oldExamples}`;
assert.ok(stripQuestionSentences(oldBody, oldStem).includes(oldExamples));
// A model body that restates the stem verbatim (either sentence) still loses it.
const restated = `${BODY}${GENERIC_COLLECT_QUESTION}`;
assert.equal(stripQuestionSentences(restated, GENERIC_COLLECT_QUESTION), BODY);
const secondOnly = `${BODY}说个大概年月就行,例如「2015 年夏天换了工作」。`;
assert.equal(stripQuestionSentences(secondOnly, GENERIC_COLLECT_QUESTION), BODY);
// A near-copy (one or two words reworded) is still a restatement (BUG-585).
assert.equal(
stripQuestionSentences("记下了。家里如果有结婚、添丁或住院的事,记得大概哪年就行。", "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。"),
"记下了。",
);
});
test("(c) the opening body is plain: no jargon, no question, no years, no examples list", () => {
assert.equal(BODY, BODY_SENTENCES.join(""));
for (const word of ["大运", "盘面", "代表分钟", "精确到秒"]) {
assert.equal(BODY.includes(word), false, word);
assert.ok((OPENING_BODY_JARGON as readonly string[]).includes(word), word);
}
assert.doesNotMatch(BODY, /[??]/);
assert.equal(isAcceptableOpeningBody(BODY, RANGE), true);
// 原值: 「我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」
// 新值: 上面两句后面再加限度两句(不保证精确到某一分钟;说不准就照实记;可以停)
// 原因: BUG-1445 开场要说清这一步做不到什么
assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。");
// The old fallback body is no longer acceptable from the model.
assert.equal(isAcceptableOpeningBody("眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。", RANGE), false);
assert.equal(isAcceptableOpeningBody("你好,我是生时校正助手。", RANGE), false);
assert.equal(isAcceptableOpeningBody(`${BODY}比如上大学、第一份工作、搬到别的城市。`, RANGE), false);
assert.equal(isAcceptableOpeningBody(openingSpokenBody(["04:50", "05:10"]), RANGE), false, "wrong window");
});
test("segment-product opening asks which chart to read while nullable history keeps the original minute goal", () => {
const identity = productDeliveryForDomain("career");
assert.ok(identity);
const spoken = openingSpokenBody(RANGE, identity);
// 原值: 产品开场只有「能不能选定用来解读的星盘」和「做法很简单」两句
// 新值: 这两句之后同样加上限度两句
// 原因: BUG-1445 产品口径也要说清不保证精确到某一分钟
assert.equal(spoken, "我们先判断现在的出生时间能不能选定用来解读的星盘,核对窗口是 04:45–05:15。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。");
assert.equal(isAcceptableOpeningBody(spoken, RANGE, identity), true);
assert.equal(isAcceptableOpeningBody(BODY, RANGE, identity), false);
assert.equal(openingSpokenBody(RANGE, null), BODY);
assert.equal(isAcceptableOpeningBody(BODY, RANGE, null), true);
assert.equal(isAcceptableOpeningBody(`${spoken}现在要把范围缩小。`, RANGE, identity), false);
assert.equal(isAcceptableOpeningBody(`${spoken}已确认。`, RANGE, identity), false);
assert.equal(isAcceptableOpeningBody(openingSpokenBody(["04:50", "05:10"], identity), RANGE, identity), false);
assert.doesNotMatch(spoken, /(?:19|20)\d{2}|[??]|代表分钟|分盘|大运/);
// 原值: 2 句。新值: 4 句。原因: BUG-1445 限度是两句,接在原来的两句后面
assert.equal(spoken.split(/(?<=。)/).filter(Boolean).length, 4);
for (const example of EXAMPLES) assert.equal(spoken.includes(example), false);
const composed = composeCollectSpokenAssistantText(spoken, GENERIC_COLLECT_QUESTION);
const [turn] = attachQuestionsToTurns([{ id: "t-product", role: "assistant", text: composed, status: "completed" }], [openingFocus("t-product")]);
assert.equal(turn?.text, spoken);
assert.equal(turn?.question?.prompt, GENERIC_COLLECT_QUESTION);
const live: SnapshotTurnMessage[] = [{ role: "assistant", turnId: "t-product", text: composed, renderKey: "product-live" }];
const [merged] = mergeTurnQuestions(live, [turn!]);
assert.equal(merged?.text, spoken);
assert.equal(merged?.question?.prompt, GENERIC_COLLECT_QUESTION);
});
test("(d) repeated tool calls show one step each; the count is the shown steps; no internal names", () => {
let trace = emptyActivityTrace();
for (const [index, tool] of ([
"rectification-read-case",
"rectification-set-focus",
"rectification-set-focus",
] as const).entries()) {
trace = startActivityTraceStep(trace, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool], index + 1);
trace = completeActivityTraceStep(trace, tool, RECTIFICATION_TOOL_DONE_LABELS[tool]);
}
assert.equal(trace.length, 3, "the live trace keeps one row per call");
const rows = rectificationTimelineRows({ trace, receipt: undefined, activity: undefined, settled: true });
assert.deepEqual(rows.map((row) => row.label), ["看了你的资料", "准备好下一个问题"]);
assert.equal(timelineSummaryLabel(rows, false), "已完成 2 步");
// Two tools that share a plain label are one step too.
let shared = emptyActivityTrace();
for (const tool of ["rectification-record-evidence-batch", "rectification-propose-evidence"] as const) {
shared = startActivityTraceStep(shared, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool]);
shared = completeActivityTraceStep(shared, tool, RECTIFICATION_TOOL_DONE_LABELS[tool]);
}
assert.deepEqual(
rectificationTimelineRows({ trace: shared, receipt: undefined, activity: undefined, settled: true }).map((row) => row.label),
["记下你说的事"],
);
assert.equal(
rectificationCompletedTrail(["rectification-read-case", "rectification-set-focus", "rectification-set-focus"]),
"已完成:看了你的资料 · 准备好下一个问题",
);
const every = [
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
...Object.values(RECTIFICATION_TOOL_PROGRESS_LABELS),
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
].join("\n");
for (const jargon of ["对话焦点", "校正记录", "事件证据", "候选", "稳健性", "焦点"]) {
assert.equal(every.includes(jargon), false, jargon);
}
assert.equal(RECTIFICATION_TOOL_DONE_LABELS["rectification-read-case"], "看了你的资料");
assert.equal(RECTIFICATION_TOOL_DONE_LABELS["rectification-set-focus"], "准备好下一个问题");
// In-progress lines reuse the VOICE-approved stage sentences (BUG-1047).
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-set-focus"], "正在准备下一个问题…");
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-propose-evidence"], "正在记下这件事…");
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-compare-candidates"], "正在重新对照盘面…");
});