refactor: make birth time rectification conversational
This commit is contained in:
@@ -205,16 +205,6 @@ test("a bound rectification session and a homepage restart share the v4 active-c
|
||||
assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
|
||||
});
|
||||
|
||||
test("historical completed rectification does not replace the account's unfinished case", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("function handleConversationalRectificationTurn");
|
||||
const end = source.indexOf("async function draftSynastryQuestionFromChart", start);
|
||||
const handler = source.slice(start, end);
|
||||
|
||||
assert.match(handler, /current\.rectificationCase\?\.caseId === turn\.caseId[\s\S]*?\? null[\s\S]*?: current\.rectificationCase/);
|
||||
assert.match(handler, /turn\.status === "completed" \|\| turn\.status === "abandoned"\) return latest/);
|
||||
});
|
||||
|
||||
test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("function chooseConversationSuggestion");
|
||||
|
||||
@@ -1,843 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
ConversationalRectificationSurface,
|
||||
} from "../src/components/conversational-birth-time-rectification.tsx";
|
||||
import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
|
||||
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import { rectificationV4ChatMessages } from "../src/components/rectification-v4-panel.tsx";
|
||||
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
|
||||
|
||||
// The repository keeps JSX in preserve mode for Next.js; direct Node SSR needs the classic global.
|
||||
Object.assign(globalThis, { React });
|
||||
const id = "00000000-0000-4000-8000-000000000901";
|
||||
const questionId = "00000000-0000-4000-8000-000000000902";
|
||||
const now = "2026-07-27T00:00:00.000Z";
|
||||
|
||||
const turn: ConversationalRectificationTurn = {
|
||||
caseId: "00000000-0000-4000-8000-000000000821",
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status: "confirming",
|
||||
turnVersion: 6,
|
||||
narrative: "## 当前判断\n\n**05:18** 只是待验证候选;D9 与 D10 仍需真实经历交叉验证。",
|
||||
candidate: {
|
||||
status: "ready_for_confirmation",
|
||||
representativeTime: "05:18",
|
||||
rangeStart: "05:16",
|
||||
rangeEnd: "05:20",
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9", "D10"],
|
||||
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
|
||||
},
|
||||
evidenceRequest: {
|
||||
domains: ["relationship", "career", "relocation"],
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
evidenceRecap: [{
|
||||
id: "00000000-0000-4000-8000-000000000822",
|
||||
summary: "开始第一份长期工作",
|
||||
dateLabel: "2021 年 7 月",
|
||||
domain: "career",
|
||||
isCorrection: false,
|
||||
}],
|
||||
actions: ["answer", "pause", "abandon", "confirm"],
|
||||
pendingConsultationQuestion: "我适合什么时候换工作?",
|
||||
};
|
||||
|
||||
const messages = [{
|
||||
role: "user" as const,
|
||||
text: "2021 年 7 月开始第一份长期工作。",
|
||||
renderKey: "user-history-1",
|
||||
}, {
|
||||
role: "assistant" as const,
|
||||
text: "这段事业经历很有区分度。目前已经形成一个待确认候选,下一步请说说一段重要关系经历。",
|
||||
renderKey: "assistant-history-1",
|
||||
}];
|
||||
|
||||
function controller(overrides: Partial<ConversationalRectificationController> = {}): ConversationalRectificationController {
|
||||
function response(overrides: Record<string, unknown> = {}): RectificationV4ApiResponse {
|
||||
return {
|
||||
turn,
|
||||
messages,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
error: "",
|
||||
getSnapshot: () => ({
|
||||
turn, messages, draft: "", selectedDomain: null, correctionTarget: null, pending: false, error: "",
|
||||
}),
|
||||
subscribe: () => () => undefined,
|
||||
synchronizeInitialTurn: () => undefined,
|
||||
setDraft: () => undefined,
|
||||
selectDomain: () => undefined,
|
||||
beginEvidenceCorrection: () => undefined,
|
||||
cancelEvidenceCorrection: () => undefined,
|
||||
start: async () => turn,
|
||||
resume: async () => turn,
|
||||
answer: async () => turn,
|
||||
regenerate: async () => turn,
|
||||
pause: async () => turn,
|
||||
abandon: async () => turn,
|
||||
confirm: async () => turn,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function surfaceProps(value: ConversationalRectificationController) {
|
||||
return {
|
||||
controller: value,
|
||||
models: [{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }] as const,
|
||||
selectedModelId: "deepseek-chat",
|
||||
onSelectModel: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
test("rectification is a language-first exchange with one free-text answer path", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controller()),
|
||||
));
|
||||
|
||||
assert.match(markup, /2021 年 7 月开始第一份长期工作/);
|
||||
assert.match(markup, /这段事业经历很有区分度/);
|
||||
assert.match(markup, /目前已经形成一个待确认候选/);
|
||||
assert.doesNotMatch(markup, /当前候选 05:18|校正进度 ·/);
|
||||
assert.doesNotMatch(markup, /D9 与 D10|当前判断/);
|
||||
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
|
||||
assert.match(markup, /像聊天一样回答即可/);
|
||||
assert.match(markup, /2018 年 6 月去了上海工作/);
|
||||
assert.equal((markup.match(/<textarea/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /data-evidence-domain=|<select|<fieldset/);
|
||||
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
|
||||
});
|
||||
|
||||
test("a resumed legacy turn replaces repeated technical prose with actionable guidance", () => {
|
||||
const legacyTurn = {
|
||||
...turn,
|
||||
status: "active",
|
||||
candidate: { ...turn.candidate, status: "pending_validation" },
|
||||
narrative: "05:30 是范围内的待验证候选。D1 保持稳定;D9 与 D24 呈现分钟敏感差异。",
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controller({ turn: legacyTurn })),
|
||||
));
|
||||
|
||||
assert.match(markup, /2021 年 7 月开始第一份长期工作/);
|
||||
assert.match(markup, /下一步请说说一段重要关系经历/);
|
||||
assert.doesNotMatch(markup, /D1 保持稳定/);
|
||||
});
|
||||
|
||||
test("an incomplete concrete event gets a targeted clarification instead of the generic question", () => {
|
||||
const incompleteTurn = {
|
||||
...turn,
|
||||
status: "active",
|
||||
candidate: { ...turn.candidate, status: "pending_validation" },
|
||||
evidenceRecap: [{
|
||||
...turn.evidenceRecap[0]!,
|
||||
summary: "离开家去北京开始工作",
|
||||
dateLabel: "日期待补充",
|
||||
domain: "relocation",
|
||||
}],
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controller({
|
||||
turn: incompleteTurn,
|
||||
messages: [{
|
||||
role: "assistant",
|
||||
text: "你提到“离开家去北京开始工作”,它大致是什么年月?",
|
||||
renderKey: "assistant-clarification",
|
||||
}],
|
||||
})),
|
||||
));
|
||||
|
||||
assert.match(markup, /你提到“离开家去北京开始工作”/);
|
||||
assert.match(markup, /大致是什么年月/);
|
||||
assert.doesNotMatch(markup, /接下来请说一件/);
|
||||
});
|
||||
|
||||
test("an uninitialized surface shows progress without a second start card", () => {
|
||||
const emptyController = controller({
|
||||
turn: null,
|
||||
pending: true,
|
||||
getSnapshot: () => ({
|
||||
turn: null,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: true,
|
||||
error: "",
|
||||
}),
|
||||
});
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(emptyController),
|
||||
));
|
||||
|
||||
assert.match(markup, /正在建立校正记录/);
|
||||
assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/);
|
||||
});
|
||||
|
||||
test("evidence history stays out of the chat surface while confirmation remains explicit", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controller()),
|
||||
));
|
||||
|
||||
assert.doesNotMatch(markup, /已记录的真实经历|更正这条经历:开始第一份长期工作/);
|
||||
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
|
||||
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
|
||||
assert.doesNotMatch(markup, /当前候选 05:18|待确认,尚未验证/);
|
||||
assert.match(markup, /确认采用 05:18(尚未验证)/);
|
||||
assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/);
|
||||
assert.doesNotMatch(markup, /已记录 1 条经历|候选只用于继续验证|这一步不会自动采用候选/);
|
||||
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
|
||||
});
|
||||
|
||||
test("terminal workflow states do not append synthetic assistant bubbles", () => {
|
||||
for (const state of [
|
||||
{ status: "paused", candidateStatus: "pending_validation" },
|
||||
{ status: "abandoned", candidateStatus: "pending_validation" },
|
||||
{ status: "completed", candidateStatus: "confirmed" },
|
||||
] as const) {
|
||||
const terminalTurn = {
|
||||
...turn,
|
||||
status: state.status,
|
||||
candidate: { ...turn.candidate, status: state.candidateStatus },
|
||||
actions: [],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controller({ turn: terminalTurn })),
|
||||
));
|
||||
|
||||
assert.doesNotMatch(markup, /校正已暂停,输入与现有证据都已保留/);
|
||||
assert.doesNotMatch(markup, /本次校正已放弃,候选时间没有应用/);
|
||||
assert.doesNotMatch(markup, /候选时间已经过你的明确确认|候选范围已保存/);
|
||||
}
|
||||
});
|
||||
|
||||
test("an active correction target remains cancellable without rendering evidence history", () => {
|
||||
const revisedTurn = {
|
||||
...turn,
|
||||
evidenceRecap: [{ ...turn.evidenceRecap[0]!, isCorrection: true }],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const correctionTarget = revisedTurn.evidenceRecap[0]!;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controller({
|
||||
turn: revisedTurn,
|
||||
draft: "更正:其实是 2020 年 11 月离职",
|
||||
correctionTarget,
|
||||
getSnapshot: () => ({
|
||||
turn: revisedTurn,
|
||||
draft: "更正:其实是 2020 年 11 月离职",
|
||||
selectedDomain: null,
|
||||
correctionTarget,
|
||||
pending: false,
|
||||
error: "",
|
||||
}),
|
||||
})),
|
||||
));
|
||||
|
||||
assert.match(markup, /正在更正/);
|
||||
assert.match(markup, /开始第一份长期工作/);
|
||||
assert.match(markup, /取消更正/);
|
||||
assert.doesNotMatch(markup, /(已修订)|已记录的真实经历/);
|
||||
});
|
||||
|
||||
test("v4 markup and responsive CSS expose accessibility and uncertainty contracts", () => {
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const component = readFileSync(
|
||||
new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const wrapper = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(component, /<section className="rectification-v4-panel" aria-busy=\{processing \|\| controller\.pending\}>/);
|
||||
assert.match(component, /id="rectification-v4-answer"/);
|
||||
assert.match(component, /disabled=\{controller\.pending\}/);
|
||||
assert.match(component, /aria-label="提交这段经历"/);
|
||||
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.match(component, /候选范围,不是已确认的出生分钟/);
|
||||
assert.match(component, /不会把峰值分钟当作真实出生时间/);
|
||||
assert.match(component, /保存这个范围/);
|
||||
assert.match(component, /protocol: "rectification-evidence-v4"[\s\S]*?caseId: caseValue\.id,[\s\S]*?caseVersion: caseValue\.version,[\s\S]*?acceptedRange: accepted/);
|
||||
assert.match(wrapper, /<RectificationV4Panel[\s\S]*?onPendingChange=\{props\.onPendingChange\}/);
|
||||
assert.match(css, /\.rectification-v4-panel \{[^}]*width: min\(860px, 100%\)[^}]*overflow-y: auto/);
|
||||
assert.match(css, /\.rectification-v4-composer textarea \{[^}]*min-height: 112px/);
|
||||
assert.match(css, /@media\s*\(max-width:\s*680px\)[\s\S]*?\.rectification-v4-ranges, \.rectification-v4-evidence-grid \{ grid-template-columns: 1fr; \}/);
|
||||
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
|
||||
});
|
||||
|
||||
test("the v4 panel rehydrates the active case and event revisions instead of session transcript state", () => {
|
||||
const wrapper = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const client = readFileSync(new URL("../src/lib/rectification-v4/client.ts", import.meta.url), "utf8");
|
||||
const v4Export = wrapper.slice(wrapper.indexOf("export function ConversationalBirthTimeRectification"));
|
||||
|
||||
assert.match(v4Export, /return \([\s\S]*?<RectificationV4Panel/);
|
||||
assert.doesNotMatch(v4Export, /initialTurn|initialMessages|useConversationalRectification/);
|
||||
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /await loadRectificationV4\(existingHandoff\.caseId\)/);
|
||||
assert.match(hook, /await createRectificationV4\(\)/);
|
||||
assert.match(client, /rectificationV4ApiResponseSchema/);
|
||||
});
|
||||
|
||||
type CdpResponse = Readonly<{
|
||||
id?: number;
|
||||
result?: unknown;
|
||||
error?: Readonly<{ message?: string }>;
|
||||
}>;
|
||||
|
||||
const CDP_CONNECT_TIMEOUT_MS = 3_000;
|
||||
const CDP_COMMAND_TIMEOUT_MS = 3_000;
|
||||
const EXTERNAL_PROBE_TIMEOUT_MS = 1_000;
|
||||
|
||||
async function withHardDeadline<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
onTimeout?: () => void,
|
||||
): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
try {
|
||||
onTimeout?.();
|
||||
} catch {
|
||||
// Timeout still rejects even if the best-effort cancellation hook itself fails.
|
||||
}
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, deadline]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpSession {
|
||||
private nextId = 0;
|
||||
private readonly pending = new Map<number, {
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}>();
|
||||
private closedError: Error | null = null;
|
||||
|
||||
private constructor(private readonly socket: WebSocket) {
|
||||
socket.addEventListener("message", (event) => {
|
||||
let message: CdpResponse;
|
||||
try {
|
||||
message = JSON.parse(String(event.data)) as CdpResponse;
|
||||
} catch {
|
||||
this.rejectPending(new Error("Chromium CDP returned malformed JSON"));
|
||||
return;
|
||||
}
|
||||
if (message.id === undefined) return;
|
||||
const request = this.pending.get(message.id);
|
||||
if (!request) return;
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(request.timeout);
|
||||
if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed"));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
this.rejectPending(new Error("Chromium CDP socket closed"));
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
this.rejectPending(new Error("Chromium CDP socket failed"));
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string): Promise<CdpSession> {
|
||||
const socket = new WebSocket(url);
|
||||
await withHardDeadline(new Promise<void>((resolveConnection, rejectConnection) => {
|
||||
const connected = () => {
|
||||
cleanup();
|
||||
resolveConnection();
|
||||
};
|
||||
const failed = () => {
|
||||
cleanup();
|
||||
rejectConnection(new Error("Unable to connect to Chromium CDP"));
|
||||
};
|
||||
const closed = () => {
|
||||
cleanup();
|
||||
rejectConnection(new Error("Chromium CDP closed before connecting"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
socket.removeEventListener("open", connected);
|
||||
socket.removeEventListener("error", failed);
|
||||
socket.removeEventListener("close", closed);
|
||||
};
|
||||
socket.addEventListener("open", connected, { once: true });
|
||||
socket.addEventListener("error", failed, { once: true });
|
||||
socket.addEventListener("close", closed, { once: true });
|
||||
}), CDP_CONNECT_TIMEOUT_MS, "Chromium CDP connection", () => socket.close());
|
||||
return new CdpSession(socket);
|
||||
}
|
||||
|
||||
async send(method: string, params: unknown = {}): Promise<unknown> {
|
||||
if (this.closedError) throw this.closedError;
|
||||
if (this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("Chromium CDP socket is not open");
|
||||
}
|
||||
const id = ++this.nextId;
|
||||
const response = new Promise<unknown>((resolveResponse, rejectResponse) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this.pending.delete(id)) return;
|
||||
rejectResponse(new Error(`Timed out waiting for CDP command ${method}`));
|
||||
}, CDP_COMMAND_TIMEOUT_MS);
|
||||
this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse, timeout });
|
||||
});
|
||||
try {
|
||||
this.socket.send(JSON.stringify({ id, method, params }));
|
||||
} catch (error) {
|
||||
const request = this.pending.get(id);
|
||||
if (request) {
|
||||
this.pending.delete(id);
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async evaluate<T>(expression: string): Promise<T> {
|
||||
const response = await this.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
}) as {
|
||||
result?: { value?: T };
|
||||
exceptionDetails?: { text?: string };
|
||||
};
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.text ?? `Browser evaluation failed: ${expression}`);
|
||||
}
|
||||
return response.result?.value as T;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.rejectPending(new Error("Chromium CDP session closed"));
|
||||
if (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(error: Error) {
|
||||
this.closedError ??= error;
|
||||
for (const request of this.pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function chromiumExecutable(): string {
|
||||
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
|
||||
const systemCandidates = [
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
];
|
||||
|
||||
const cacheRoots = [
|
||||
join(homedir(), "Library/Caches/ms-playwright"),
|
||||
join(homedir(), ".cache/ms-playwright"),
|
||||
];
|
||||
for (const cacheRoot of cacheRoots) {
|
||||
if (!existsSync(cacheRoot)) continue;
|
||||
const headlessVersions = readdirSync(cacheRoot)
|
||||
.filter((entry) => entry.startsWith("chromium_headless_shell-"))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const version of headlessVersions) {
|
||||
const candidates = [
|
||||
join(cacheRoot, version, "chrome-headless-shell-mac-arm64/chrome-headless-shell"),
|
||||
join(cacheRoot, version, "chrome-headless-shell-mac-x64/chrome-headless-shell"),
|
||||
join(cacheRoot, version, "chrome-headless-shell-linux64/chrome-headless-shell"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
const versions = readdirSync(cacheRoot)
|
||||
.filter((entry) => entry.startsWith("chromium-"))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const version of versions) {
|
||||
const candidates = [
|
||||
join(cacheRoot, version, "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
|
||||
join(cacheRoot, version, "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
|
||||
join(cacheRoot, version, "chrome-linux/chrome"),
|
||||
join(cacheRoot, version, "chrome-linux64/chrome"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const candidate of systemCandidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("A Playwright headless Chromium is required for the real DOM contract; install it without pointing CHROME_PATH at the user's desktop Chrome.");
|
||||
}
|
||||
|
||||
async function waitFor<T>(probe: () => Promise<T | null | false>, label: string): Promise<T> {
|
||||
const deadline = Date.now() + 8_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const remaining = deadline - Date.now();
|
||||
const value = await withHardDeadline(
|
||||
Promise.resolve().then(probe),
|
||||
Math.max(1, Math.min(EXTERNAL_PROBE_TIMEOUT_MS, remaining)),
|
||||
`${label} probe`,
|
||||
);
|
||||
if (value !== null && value !== false) return value;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0) {
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(30, remaining)));
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`);
|
||||
}
|
||||
|
||||
async function fetchJsonWithDeadline<T>(url: string): Promise<T> {
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
const response = await withHardDeadline(
|
||||
fetch(url, { signal: abort.signal }),
|
||||
EXTERNAL_PROBE_TIMEOUT_MS,
|
||||
`fetch ${url}`,
|
||||
() => abort.abort(),
|
||||
);
|
||||
if (!response.ok) throw new Error(`Chromium target list returned HTTP ${response.status}`);
|
||||
return await withHardDeadline(
|
||||
response.json() as Promise<T>,
|
||||
EXTERNAL_PROBE_TIMEOUT_MS,
|
||||
`JSON body from ${url}`,
|
||||
() => abort.abort(),
|
||||
);
|
||||
} finally {
|
||||
abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateChildProcess(browser: ChildProcess): Promise<void> {
|
||||
try {
|
||||
const alreadyExited = browser.exitCode !== null || browser.signalCode !== null;
|
||||
if (!alreadyExited) {
|
||||
const closed = new Promise<void>((resolveClosed) => {
|
||||
const done = () => {
|
||||
browser.removeListener("close", done);
|
||||
browser.removeListener("error", done);
|
||||
resolveClosed();
|
||||
};
|
||||
browser.once("close", done);
|
||||
browser.once("error", done);
|
||||
});
|
||||
browser.kill("SIGKILL");
|
||||
await withHardDeadline(closed, 3_000, "Chromium process exit");
|
||||
}
|
||||
} finally {
|
||||
browser.stdout?.destroy();
|
||||
browser.stderr?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{
|
||||
browser: ChildProcess;
|
||||
cdp: CdpSession;
|
||||
}> {
|
||||
const executable = chromiumExecutable();
|
||||
const browser = spawn(executable, [
|
||||
executable.includes("chrome-headless-shell") ? "--headless" : "--headless=new",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-default-apps",
|
||||
"--disable-extensions",
|
||||
"--disable-features=Translate",
|
||||
"--disable-gpu",
|
||||
"--disable-sync",
|
||||
"--no-first-run",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${userDataDirectory}`,
|
||||
pathToFileURL(htmlPath).href,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let launchError: Error | null = null;
|
||||
let cdp: CdpSession | null = null;
|
||||
browser.on("error", (error) => {
|
||||
launchError = error;
|
||||
});
|
||||
browser.stdout?.resume();
|
||||
browser.stderr?.resume();
|
||||
try {
|
||||
const activePort = join(userDataDirectory, "DevToolsActivePort");
|
||||
const port = await waitFor(async () => {
|
||||
if (launchError) throw launchError;
|
||||
if (browser.exitCode !== null || browser.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Chromium exited before CDP was ready (${browser.exitCode ?? browser.signalCode})`,
|
||||
);
|
||||
}
|
||||
if (!existsSync(activePort)) return null;
|
||||
const parsed = Number(readFileSync(activePort, "utf8").split("\n")[0]);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
|
||||
throw new Error("Chromium wrote an invalid DevTools port");
|
||||
}
|
||||
return parsed;
|
||||
}, "Chromium DevTools port");
|
||||
const target = await waitFor(async () => {
|
||||
const targets = await fetchJsonWithDeadline<Array<{
|
||||
type?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}>>(`http://127.0.0.1:${port}/json/list`);
|
||||
return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null;
|
||||
}, "Chromium page target");
|
||||
cdp = await CdpSession.connect(target);
|
||||
return { browser, cdp };
|
||||
} catch (error) {
|
||||
cdp?.close();
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await terminateChildProcess(browser);
|
||||
} catch (caught) {
|
||||
cleanupError = caught;
|
||||
} finally {
|
||||
rmSync(userDataDirectory, { force: true, recursive: true });
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Chromium fixture launch and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("Chromium harness keeps the browser sandbox and bounds every external wait", () => {
|
||||
const source = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
||||
const unsafeSandboxFlag = ["--no", "sandbox"].join("-");
|
||||
|
||||
assert.equal(source.includes(`"${unsafeSandboxFlag}"`), false);
|
||||
assert.match(source, /withHardDeadline/);
|
||||
assert.match(source, /rejectPending/);
|
||||
assert.match(source, /terminateChildProcess/);
|
||||
assert.match(source, /fetchJsonWithDeadline/);
|
||||
});
|
||||
|
||||
test("real Chromium at 390px verifies the v4 range surface, keyboard focus, and no horizontal overflow", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const directory = mkdtempSync(join(tmpdir(), "rectification-v4-browser-"));
|
||||
const entryPath = join(directory, "fixture.tsx");
|
||||
const bundlePath = join(directory, "fixture.js");
|
||||
const htmlPath = join(directory, "fixture.html");
|
||||
const userDataDirectory = join(directory, "chrome-profile");
|
||||
const componentPath = join(frontendRoot, "src/components/rectification-v4-panel.tsx");
|
||||
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
|
||||
.replace(/^@import[^;]+;\s*/gm, "");
|
||||
const fixture = `
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RectificationV4Panel } from ${JSON.stringify(componentPath)};
|
||||
|
||||
const eventId = "00000000-0000-4000-8000-000000000822";
|
||||
const now = "2026-07-27T00:00:00.000Z";
|
||||
const data = {
|
||||
case: {
|
||||
id: "00000000-0000-4000-8000-000000000821",
|
||||
userId: "00000000-0000-4000-8000-000000000823",
|
||||
protocol: "rectification-evidence-v4",
|
||||
version: 4,
|
||||
status: "range_ready",
|
||||
phase: "complete",
|
||||
calculationSpec: {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: "1990-01-01",
|
||||
candidateRange: { start: "05:00", end: "06:00" },
|
||||
latitude: 25.03,
|
||||
longitude: 121.56,
|
||||
timezoneOffsetHours: 8,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
},
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
currentQuestion: {
|
||||
id: "00000000-0000-4000-8000-000000000824",
|
||||
domain: "career",
|
||||
targetEventId: eventId,
|
||||
prompt: "如果要修订这段经历,请直接写出正确年月和发生了什么。",
|
||||
recallCost: "low",
|
||||
reason: "核对日期敏感性",
|
||||
},
|
||||
latestSnapshot: {
|
||||
id: "00000000-0000-4000-8000-000000000825",
|
||||
caseId: "00000000-0000-4000-8000-000000000821",
|
||||
caseVersion: 4,
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
algorithmVersion: "rectification-v4-range-scoring-1",
|
||||
candidates: [{
|
||||
time: "05:18",
|
||||
score: 9.5,
|
||||
supportingEventIds: [eventId],
|
||||
conflictingEventIds: [],
|
||||
}],
|
||||
clusters: [{
|
||||
rank: 1,
|
||||
startTime: "05:16",
|
||||
endTime: "05:20",
|
||||
representativeTime: "05:18",
|
||||
widthMinutes: 5,
|
||||
peakScore: 9.5,
|
||||
scoreMass: 9.5,
|
||||
}],
|
||||
robustness: {
|
||||
neighborSupportMinutes: 5,
|
||||
leaveOneOutRetentionRate: 0.8,
|
||||
dateSensitivityRetentionRate: 0.9,
|
||||
calculationSpecHashMatched: true,
|
||||
},
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: true,
|
||||
gateReasons: [],
|
||||
createdAt: now,
|
||||
},
|
||||
acceptedRange: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
case: {
|
||||
id,
|
||||
userId: "00000000-0000-4000-8000-000000000900",
|
||||
protocol: "rectification-evidence-v4",
|
||||
version: 2,
|
||||
status: "awaiting_answer",
|
||||
phase: "collecting_evidence",
|
||||
calculationSpec: {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: "1997-08-08",
|
||||
candidateRange: { start: "04:50", end: "05:10" },
|
||||
latitude: 36.419,
|
||||
longitude: 114.213,
|
||||
timezoneOffsetHours: 8,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
},
|
||||
job: null,
|
||||
events: [{
|
||||
id: "00000000-0000-4000-8000-000000000826",
|
||||
eventId,
|
||||
revision: 2,
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
summary: "2021 年开始第一份长期工作",
|
||||
rawText: "2021 年 7 月开始第一份长期工作",
|
||||
dateRange: { start: "2021-07-01", end: "2021-07-31", precision: "month", label: "2021-07" },
|
||||
scoreability: "scoreable",
|
||||
supersedesRevisionId: null,
|
||||
createdAt: now,
|
||||
}],
|
||||
};
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
currentQuestion: {
|
||||
id: questionId,
|
||||
domain: "relocation",
|
||||
targetEventId: null,
|
||||
prompt: "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?",
|
||||
recallCost: "low",
|
||||
reason: "根据对话选择下一条高信息量追问。",
|
||||
},
|
||||
latestSnapshot: null,
|
||||
acceptedRange: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
},
|
||||
job: null,
|
||||
events: [],
|
||||
turns: [{
|
||||
id: "00000000-0000-4000-8000-000000000903",
|
||||
caseId: id,
|
||||
caseVersion: 1,
|
||||
questionId: "00000000-0000-4000-8000-000000000904",
|
||||
questionDomain: "other",
|
||||
questionTargetEventId: null,
|
||||
question: "请从你最确定的一段人生经历开始说。",
|
||||
answer: "2015年毕业后复读,2016年再次毕业。",
|
||||
modelId: "gpt-5.5",
|
||||
actionId: "00000000-0000-4000-8000-000000000905",
|
||||
createdAt: now,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/rectification/v4/handoff" && !init?.method) return new Response(null, { status: 204 });
|
||||
if (path === "/api/rectification/v4/cases" && init?.method === "POST") {
|
||||
return Response.json(data);
|
||||
}
|
||||
throw new Error("unexpected fetch " + path);
|
||||
};
|
||||
createRoot(document.getElementById("root")).render(<RectificationV4Panel />);
|
||||
globalThis.__rectificationReady = true;
|
||||
`;
|
||||
let browser: ChildProcess | null = null;
|
||||
let cdp: CdpSession | null = null;
|
||||
try {
|
||||
writeFileSync(entryPath, fixture);
|
||||
await withHardDeadline(build({
|
||||
absWorkingDir: frontendRoot,
|
||||
bundle: true,
|
||||
define: { "process.env.NODE_ENV": '"test"' },
|
||||
entryPoints: [entryPath],
|
||||
format: "iife",
|
||||
jsx: "automatic",
|
||||
logLevel: "silent",
|
||||
nodePaths: [join(frontendRoot, "node_modules")],
|
||||
outfile: bundlePath,
|
||||
platform: "browser",
|
||||
}), 10_000, "browser fixture bundle");
|
||||
writeFileSync(htmlPath, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:auto;overflow:auto}body{padding:12px}#root{width:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
|
||||
test("v4 rectification reuses the ordinary session message list, composer, and model selector", () => {
|
||||
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
|
||||
const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory));
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Page.bringToFront");
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 390,
|
||||
height: 844,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
});
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("globalThis.__rectificationReady === true") ?? Promise.resolve(false),
|
||||
"React fixture readiness",
|
||||
);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('05:16–05:20')
|
||||
&& document.body.textContent.includes('保存这个范围')
|
||||
&& document.getElementById('rectification-v4-answer') !== null`) ?? Promise.resolve(false),
|
||||
"v4 range surface",
|
||||
);
|
||||
|
||||
const layout = await cdp.evaluate<{
|
||||
viewport: number;
|
||||
scrollWidth: number;
|
||||
panelWidth: number;
|
||||
rangeColumns: string;
|
||||
exactMinuteClaimVisible: boolean;
|
||||
}>(`(() => {
|
||||
const panel = document.querySelector('.rectification-v4-panel');
|
||||
return {
|
||||
viewport: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
panelWidth: panel.getBoundingClientRect().width,
|
||||
rangeColumns: getComputedStyle(document.querySelector('.rectification-v4-ranges')).gridTemplateColumns,
|
||||
exactMinuteClaimVisible: document.body.textContent.includes('已确认出生分钟'),
|
||||
};
|
||||
})()`);
|
||||
assert.equal(layout.viewport, 390);
|
||||
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
|
||||
assert.ok(layout.panelWidth <= 366, `panel overflowed padded viewport: ${layout.panelWidth}px`);
|
||||
assert.equal(layout.rangeColumns.trim().split(/\s+/).length, 1);
|
||||
assert.equal(layout.exactMinuteClaimVisible, false);
|
||||
|
||||
await cdp.evaluate("document.getElementById('rectification-v4-answer').focus()");
|
||||
assert.equal(
|
||||
await cdp.evaluate<boolean>("document.activeElement?.id === 'rectification-v4-answer'"),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
cdp?.close();
|
||||
if (browser) await terminateChildProcess(browser);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
assert.match(component, /className="message-list"/);
|
||||
assert.match(component, /<ChatMessageRow/);
|
||||
assert.match(component, /className="composer-wrap"/);
|
||||
assert.match(component, /className="composer"/);
|
||||
assert.match(component, /<Textarea/);
|
||||
assert.match(component, /<ModelSelector/);
|
||||
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
|
||||
assert.match(wrapper, /<RectificationV4Panel \{\.\.\.props\} \/>/);
|
||||
assert.match(page, /rectificationSurfaceOpen \|\| activeSession\?\.messages\.length/);
|
||||
assert.doesNotMatch(page, /is-rectification/);
|
||||
assert.match(css, /\.rectification-chat \{ display: contents; \}/);
|
||||
});
|
||||
|
||||
test("turn history and the context-aware next question render as one chat timeline", () => {
|
||||
const messages = rectificationV4ChatMessages(response(), false);
|
||||
|
||||
assert.deepEqual(messages.map(({ role, text }) => [role, text]), [
|
||||
["assistant", "请从你最确定的一段人生经历开始说。"],
|
||||
["user", "2015年毕业后复读,2016年再次毕业。"],
|
||||
["assistant", "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("processing is an ordinary assistant thinking message after the saved answer", () => {
|
||||
const messages = rectificationV4ChatMessages(response({ currentQuestion: null, status: "processing" }), true);
|
||||
assert.equal(messages.at(-1)?.role, "assistant");
|
||||
assert.equal(messages.at(-1)?.state, "thinking");
|
||||
});
|
||||
|
||||
test("range messages never claim an exact confirmed birth minute", () => {
|
||||
const rangeReady = response({
|
||||
status: "range_ready",
|
||||
phase: "complete",
|
||||
currentQuestion: null,
|
||||
latestSnapshot: {
|
||||
clusters: [{ startTime: "05:26", endTime: "05:30" }],
|
||||
canAcceptRange: true,
|
||||
},
|
||||
});
|
||||
const candidateText = rectificationV4ChatMessages(rangeReady, false).at(-1)?.text ?? "";
|
||||
assert.match(candidateText, /05:26–05:30/);
|
||||
assert.match(candidateText, /候选范围/);
|
||||
assert.match(candidateText, /不是已确认的出生分钟/);
|
||||
|
||||
const acceptedText = rectificationV4ChatMessages(response({
|
||||
status: "range_ready",
|
||||
phase: "complete",
|
||||
currentQuestion: null,
|
||||
acceptedRange: { start: "05:26", end: "05:30" },
|
||||
}), false).at(-1)?.text ?? "";
|
||||
assert.match(acceptedText, /原出生时间没有被自动改写/);
|
||||
});
|
||||
|
||||
test("the retired questionnaire panel and fixed-domain controls are absent", () => {
|
||||
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
|
||||
const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
for (const retired of [
|
||||
"生时校正 · 事件证据法",
|
||||
"rectification-v4-evidence-grid",
|
||||
"支持这个范围的经历",
|
||||
"ConversationalRectificationSurface",
|
||||
]) {
|
||||
assert.doesNotMatch(component, new RegExp(retired));
|
||||
assert.doesNotMatch(wrapper, new RegExp(retired));
|
||||
}
|
||||
assert.doesNotMatch(css, /Birth-time rectification V4|\.rectification-v4-panel/);
|
||||
});
|
||||
|
||||
@@ -68,15 +68,15 @@ test("visible rectification copy does not replace a tailored Agent answer with a
|
||||
assert.match(narrative, /事业转折/);
|
||||
});
|
||||
|
||||
test("the rectification chat renders the controller's alternating message history", () => {
|
||||
test("the v4 rectification chat renders persisted turns as alternating message history", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /controller\.messages/);
|
||||
assert.match(source, /controller\.messages[\s\S]*?\.map\(\(message\)/);
|
||||
const messageHistoryIndex = source.indexOf("controller.messages");
|
||||
assert.doesNotMatch(source, /rectification-progress-details|turn\.evidenceRecap\.map/);
|
||||
assert.ok(messageHistoryIndex >= 0);
|
||||
assert.match(source, /for \(const turn of data\.turns\)/);
|
||||
assert.match(source, /role: "assistant"[\s\S]*?text: turn\.question/);
|
||||
assert.match(source, /role: "user"[\s\S]*?text: turn\.answer/);
|
||||
assert.match(source, /messages\.map\(\(message\) => <ChatMessageRow/);
|
||||
assert.doesNotMatch(source, /rectification-progress-details|evidenceRecap\.map/);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ConversationalRectificationSurface } from "../src/components/conversational-birth-time-rectification.tsx";
|
||||
import { createConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
|
||||
import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
|
||||
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
|
||||
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import {
|
||||
@@ -13,8 +7,6 @@ import {
|
||||
createRectificationQuestionHandoffCoordinator,
|
||||
} from "../src/lib/rectification-question-handoff.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const pendingQuestion = "未来半年是否适合换工作?";
|
||||
|
||||
function confirmedTurn(): ConversationalRectificationTurn {
|
||||
@@ -43,108 +35,6 @@ function confirmedTurn(): ConversationalRectificationTurn {
|
||||
};
|
||||
}
|
||||
|
||||
function controllerFor(turn: ConversationalRectificationTurn): ConversationalRectificationController {
|
||||
const snapshot = {
|
||||
turn,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
error: "",
|
||||
} as const;
|
||||
return {
|
||||
...snapshot,
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: () => () => undefined,
|
||||
synchronizeInitialTurn: () => undefined,
|
||||
setDraft: () => undefined,
|
||||
selectDomain: () => undefined,
|
||||
beginEvidenceCorrection: () => undefined,
|
||||
cancelEvidenceCorrection: () => undefined,
|
||||
start: async () => turn,
|
||||
resume: async () => turn,
|
||||
answer: async () => turn,
|
||||
regenerate: async () => turn,
|
||||
pause: async () => turn,
|
||||
abandon: async () => turn,
|
||||
confirm: async () => turn,
|
||||
};
|
||||
}
|
||||
|
||||
function surfaceProps(
|
||||
controller: ConversationalRectificationController,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
controller,
|
||||
models: [{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }] as const,
|
||||
selectedModelId: "deepseek-chat",
|
||||
onSelectModel: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("confirmed surface requires an explicit click before continuing the original question", () => {
|
||||
let continuationCalls = 0;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controllerFor(confirmedTurn()), {
|
||||
onContinueOriginalQuestion: () => { continuationCalls += 1; },
|
||||
}),
|
||||
));
|
||||
|
||||
assert.doesNotMatch(markup, /原问题:未来半年是否适合换工作?/);
|
||||
assert.match(markup, />返回原问题</);
|
||||
assert.equal(continuationCalls, 0);
|
||||
});
|
||||
|
||||
test("continuation action is visibly locked while ordinary consultation is pending", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controllerFor(confirmedTurn()), {
|
||||
continuationPending: true,
|
||||
onContinueOriginalQuestion: () => undefined,
|
||||
}),
|
||||
));
|
||||
|
||||
assert.match(markup, /<button[^>]+disabled=""[^>]*>正在继续回答…<\/button>/);
|
||||
});
|
||||
|
||||
test("choosing rectify-first captures the visible question and passes it only to v3 start", async () => {
|
||||
const coordinator = createRectificationQuestionHandoffCoordinator<"career" | "general">();
|
||||
const commands: unknown[] = [];
|
||||
const handoff = coordinator.capture({
|
||||
question: ` ${pendingQuestion} `,
|
||||
sessionId: "session-original",
|
||||
theme: "career",
|
||||
});
|
||||
const firstTurn: ConversationalRectificationTurn = {
|
||||
...confirmedTurn(),
|
||||
status: "active" as const,
|
||||
turnVersion: 0,
|
||||
candidate: {
|
||||
...confirmedTurn().candidate,
|
||||
status: "pending_validation" as const,
|
||||
},
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
};
|
||||
const rectification = createConversationalRectificationController({
|
||||
async send(command) {
|
||||
commands.push(command);
|
||||
return firstTurn;
|
||||
},
|
||||
createActionId: () => "00000000-0000-4000-8000-000000001011",
|
||||
});
|
||||
|
||||
await rectification.start(handoff.question);
|
||||
|
||||
assert.deepEqual(commands, [{
|
||||
type: "start",
|
||||
actionId: "00000000-0000-4000-8000-000000001011",
|
||||
pendingConsultationQuestion: pendingQuestion,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("pause, refresh, and a new device recover the durable question without losing local session and theme", () => {
|
||||
const paused = {
|
||||
...confirmedTurn(),
|
||||
@@ -420,21 +310,3 @@ test("refresh restores only the server-owned pending question and replacement wi
|
||||
assert.equal(refreshed?.question, "新问题");
|
||||
assert.equal(refreshed?.turn.pendingConsultationQuestion, "新问题");
|
||||
});
|
||||
|
||||
test("confirmed surface never revives an old local question after durable consumption", () => {
|
||||
const consumed = {
|
||||
...confirmedTurn(),
|
||||
pendingConsultationQuestion: null,
|
||||
actions: [],
|
||||
};
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(controllerFor(consumed), {
|
||||
pendingConsultationQuestion: "浏览器里的旧问题",
|
||||
onContinueOriginalQuestion: () => undefined,
|
||||
}),
|
||||
));
|
||||
|
||||
assert.doesNotMatch(markup, /使用新确认时间继续回答原问题/);
|
||||
assert.doesNotMatch(markup, /浏览器里的旧问题/);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4
|
||||
import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts";
|
||||
import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts";
|
||||
import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts";
|
||||
import { planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts";
|
||||
import { openingQuestion, planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts";
|
||||
|
||||
const now = new Date("2026-07-26T00:00:00.000Z");
|
||||
|
||||
@@ -73,42 +73,44 @@ test("decision gate can accept a stable range but never an exact minute", () =>
|
||||
assert.equal(result.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
test("question planner asks one deterministic low-recall question at a time", () => {
|
||||
const question = planNextQuestion({
|
||||
askedDomains: ["education"], coveredDomains: ["education"],
|
||||
candidateSplitByDomain: { relocation: 0.8, relationship: 0.2 }, id: randomUUID(),
|
||||
});
|
||||
assert.equal(question.domain, "relocation");
|
||||
test("opening question invites free narration without a fixed domain", () => {
|
||||
const question = openingQuestion({ start: "04:50", end: "05:10" }, randomUUID());
|
||||
|
||||
assert.equal(question.domain, "other");
|
||||
assert.equal(question.targetEventId, null);
|
||||
assert.equal(question.prompt.includes("搬家"), true);
|
||||
assert.match(question.prompt, /04:50–05:10/);
|
||||
assert.match(question.prompt, /不是已确认的出生分钟/);
|
||||
assert.match(question.prompt, /不需要按固定领域回答/);
|
||||
assert.doesNotMatch(question.prompt, /毕业|搬家|恋爱|工作|财务|健康/);
|
||||
});
|
||||
|
||||
test("question planner refines imprecise scoreable events after domain coverage", () => {
|
||||
test("fallback planner refines an imprecise event, then returns to open narration", () => {
|
||||
const eventId = randomUUID();
|
||||
const event = appendEventRevision([], {
|
||||
eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业",
|
||||
rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"),
|
||||
}, { id: randomUUID(), now });
|
||||
const question = planNextQuestion({
|
||||
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
events: [event],
|
||||
attemptedRefinementEventIds: [],
|
||||
latestAnswer: "2016年高中毕业",
|
||||
id: randomUUID(),
|
||||
});
|
||||
assert.equal(question.targetEventId, eventId);
|
||||
assert.equal(question.prompt.includes("高中毕业"), true);
|
||||
assert.equal(question.domain, "education");
|
||||
assert.match(question.prompt, /高中毕业/);
|
||||
assert.match(question.prompt, /月份或日期/);
|
||||
|
||||
const fallback = planNextQuestion({
|
||||
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
events: [event],
|
||||
attemptedRefinementEventIds: [eventId],
|
||||
latestAnswer: "2016年高中毕业",
|
||||
id: randomUUID(),
|
||||
});
|
||||
assert.equal(fallback.targetEventId, null);
|
||||
assert.equal(fallback.domain, "other");
|
||||
assert.equal(fallback.prompt.includes("暂停"), true);
|
||||
assert.match(fallback.prompt, /继续讲另一件/);
|
||||
assert.doesNotMatch(fallback.prompt, /搬家|恋爱|事业|财务|健康/);
|
||||
});
|
||||
|
||||
test("targeted date answer appends a revision without duplicating the scoreable event", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
|
||||
const conversationalSql = readFileSync(new URL("../supabase/migrations/20260727010000_rectification_v4_conversational_turns.sql", import.meta.url), "utf8");
|
||||
|
||||
test("v4 migration creates canonical append-only storage and leased jobs", () => {
|
||||
for (const table of [
|
||||
@@ -19,6 +20,13 @@ test("v4 migration creates canonical append-only storage and leased jobs", () =>
|
||||
assert.match(sql, /domain not in \('family', 'other'\) or scoreability = 'context_only'/);
|
||||
});
|
||||
|
||||
test("v4 conversational migration persists the selected model with each turn", () => {
|
||||
assert.match(conversationalSql, /add column model_id text/i);
|
||||
assert.match(conversationalSql, /p_model_id text/i);
|
||||
assert.match(conversationalSql, /question, answer, model_id, action_id/i);
|
||||
assert.match(conversationalSql, /grant execute on function public\.submit_birth_time_rectification_v4_answer/i);
|
||||
});
|
||||
|
||||
test("v4 handoff SQL enforces range acceptance, leases, idempotent settlement, and no profile time write", () => {
|
||||
for (const functionName of [
|
||||
"attach_birth_time_rectification_v4_question",
|
||||
|
||||
@@ -50,6 +50,14 @@ test("fixture replay returns ranges only and never mutates the profile birth min
|
||||
const worker = createRectificationV4Worker({
|
||||
store,
|
||||
now,
|
||||
questionAuthor: async () => ({
|
||||
id: randomUUID(),
|
||||
domain: "other",
|
||||
targetEventId: null,
|
||||
prompt: "请继续讲另一件时间比较清楚的人生变化。",
|
||||
recallCost: "low",
|
||||
reason: "Replay keeps narration open instead of depending on a fixed domain order.",
|
||||
}),
|
||||
engine: {
|
||||
async score({ calculationSpec, events }) {
|
||||
const ids = events.map((event) => event.eventId);
|
||||
@@ -98,7 +106,6 @@ test("fixture replay returns ranges only and never mutates the profile birth min
|
||||
loaded.case.version,
|
||||
"2020年5月开始恋爱,2022年3月分手",
|
||||
);
|
||||
|
||||
const snapshot = loaded.case.latestSnapshot;
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.canConfirmExactMinute, false);
|
||||
|
||||
@@ -3,7 +3,6 @@ import test from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
|
||||
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
|
||||
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
||||
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
|
||||
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
|
||||
|
||||
@@ -21,7 +20,6 @@ const spec: CalculationSpec = {
|
||||
};
|
||||
|
||||
|
||||
|
||||
test("same calculation spec resumes the unfinished case", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
@@ -84,13 +82,16 @@ test("answer is durably queued and poll remains read only", async () => {
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
assert.equal(created.case.status, "awaiting_answer");
|
||||
assert.equal(created.case.currentQuestion?.domain, "education");
|
||||
assert.equal(created.case.currentQuestion?.domain, "other");
|
||||
assert.match(created.case.currentQuestion?.prompt ?? "", /不需要按固定领域回答/);
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
|
||||
modelId: "gpt-5.5",
|
||||
});
|
||||
assert.equal(queued?.case.status, "processing");
|
||||
assert.equal(queued?.job?.status, "pending");
|
||||
assert.equal(queued?.turns.at(-1)?.modelId, "gpt-5.5");
|
||||
const before = JSON.stringify([...store.jobs.values()]);
|
||||
const polled = await service.loadCase(userId, created.case.id);
|
||||
assert.equal(polled?.job, null);
|
||||
@@ -115,7 +116,9 @@ test("worker extracts dated events, keeps one question and never confirms an exa
|
||||
const done = await service.loadCase(userId, created.case.id);
|
||||
assert.equal(done?.case.status, "awaiting_answer");
|
||||
assert.equal(done?.events.length, 2);
|
||||
assert.equal(done?.case.currentQuestion?.domain, "relocation");
|
||||
assert.equal(done?.case.currentQuestion?.domain, "education");
|
||||
assert.equal(done?.events.some((event) => event.eventId === done.case.currentQuestion?.targetEventId), true);
|
||||
assert.match(done?.case.currentQuestion?.prompt ?? "", /月份或日期/);
|
||||
assert.equal(done?.case.latestSnapshot, null);
|
||||
assert.equal(queued?.job?.status, "pending");
|
||||
});
|
||||
@@ -136,81 +139,65 @@ test("completed job rejects stale case or calculation hashes", async () => {
|
||||
}, fixedNow().toISOString()), /stale_job/);
|
||||
});
|
||||
|
||||
test("worker moves from domain coverage to targeted date refinement without a null-question dead state", async () => {
|
||||
test("worker gives the selected model full conversation context for the next question", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const scoreableCounts: number[] = [];
|
||||
const contexts: Array<{
|
||||
modelId: string | null;
|
||||
turnAnswers: string[];
|
||||
eventSummaries: string[];
|
||||
}> = [];
|
||||
const worker = createRectificationV4Worker({
|
||||
store,
|
||||
now: fixedNow,
|
||||
engine: {
|
||||
async score({ calculationSpec, events }) {
|
||||
scoreableCounts.push(events.length);
|
||||
const ids = events.map((event) => event.eventId);
|
||||
return {
|
||||
resultId: randomUUID(),
|
||||
calculationSpecHash: calculationSpecHash(calculationSpec),
|
||||
candidates: [
|
||||
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
],
|
||||
robustness: {
|
||||
neighborSupportMinutes: 3,
|
||||
leaveOneOutRetentionRate: 1,
|
||||
dateSensitivityRetentionRate: 0.5,
|
||||
},
|
||||
missingLayers: [],
|
||||
};
|
||||
},
|
||||
engine: { async score() { throw new Error("engine must not run before enough events"); } },
|
||||
questionAuthor: async (context) => {
|
||||
contexts.push({
|
||||
modelId: context.modelId,
|
||||
turnAnswers: context.turns.map((turn) => turn.answer),
|
||||
eventSummaries: context.events.map((event) => event.summary),
|
||||
});
|
||||
return {
|
||||
id: randomUUID(),
|
||||
domain: "other",
|
||||
targetEventId: null,
|
||||
prompt: context.turns.length === 1
|
||||
? "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?"
|
||||
: "你提到毕业和搬家是连续发生的。那次搬家前后,生活节奏还有什么明显变化?",
|
||||
recallCost: "low",
|
||||
reason: "根据完整对话选择下一条高信息量追问。",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
let current = created;
|
||||
for (const answer of [
|
||||
"2016年高中毕业",
|
||||
"2018年8月搬家到北京",
|
||||
"2020年5月开始恋爱",
|
||||
"2021年3月入职公司",
|
||||
"2022年4月收入明显变化",
|
||||
"2023年5月住院",
|
||||
"2024年6月家庭发生变化",
|
||||
]) {
|
||||
for (const [answer, modelId] of [
|
||||
["2015年7月高中毕业后复读一年,2016年6月再次毕业", "gpt-5.5"],
|
||||
["2018年8月搬到北京,之后开始独立生活", "deepseek-chat"],
|
||||
] as const) {
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: current.case.version,
|
||||
answer,
|
||||
modelId,
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
current = (await service.loadCase(userId, created.case.id))!;
|
||||
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
|
||||
}
|
||||
|
||||
const targetEventId = current.case.currentQuestion?.targetEventId;
|
||||
assert.ok(targetEventId);
|
||||
assert.equal(current.case.status, "awaiting_answer");
|
||||
assert.equal(current.case.currentQuestion?.prompt.includes("更具体的日期"), true);
|
||||
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: current.case.version,
|
||||
answer: "2016年6月8日",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
current = (await service.loadCase(userId, created.case.id))!;
|
||||
|
||||
const targetedRevisions = current.events.filter((event) => event.eventId === targetEventId);
|
||||
assert.deepEqual(targetedRevisions.map((event) => event.revision), [1, 2]);
|
||||
assert.equal(targetedRevisions[1]?.dateRange.precision, "day");
|
||||
assert.equal(scoreableCounts.at(-1), 6);
|
||||
assert.equal(contexts.length, 2);
|
||||
assert.equal(contexts[0]?.modelId, "gpt-5.5");
|
||||
assert.deepEqual(contexts[1]?.turnAnswers, [
|
||||
"2015年7月高中毕业后复读一年,2016年6月再次毕业",
|
||||
"2018年8月搬到北京,之后开始独立生活",
|
||||
]);
|
||||
assert.equal(contexts[1]?.modelId, "deepseek-chat");
|
||||
assert.equal(contexts[1]?.eventSummaries.length, 3);
|
||||
assert.match(current.case.currentQuestion?.prompt ?? "", /毕业和搬家/);
|
||||
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
|
||||
assert.notEqual(current.case.currentQuestion?.targetEventId, targetEventId);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user