merge: land conversational rectification on main

This commit is contained in:
Jesse_Chen
2026-07-22 17:51:28 +08:00
38 changed files with 2859 additions and 704 deletions
@@ -18,4 +18,7 @@ test("onboarding keeps the current card visible while birth-time assessment is p
assert.match(source, /className="birth-time-assessment-overlay"/);
assert.match(source, /aria-busy=\{birthTimeAssessmentPhase !== null\}/);
assert.match(source, /previewMode === "birth-time-assessment-loading"/);
assert.match(source, /saveOnboardingBirth[\s\S]*?setBirthTimeAssessmentPhase\("saving_profile"\)/);
assert.match(source, /saveOnboardingPlace[\s\S]*?setBirthTimeAssessmentPhase\("entering_home"\)/);
assert.match(source, /entering_home:[\s\S]*?title: "正在进入首页"[\s\S]*?detail: "出生资料已保存,正在为你准备首页。"/);
});
+10
View File
@@ -6,6 +6,8 @@ import { chatMessageViews } from "../src/lib/chat-message-view.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const previousMessages = [
{ role: "user", text: "问题" },
@@ -42,6 +44,14 @@ test("does not duplicate a completed assistant answer while loading state settle
assert.equal(views.at(-1)?.state, "settled");
});
test("shows honest agent activity states before and during streamed text", () => {
assert.match(messageRowSource, /state="working" label="正在核对星盘信息…"/);
assert.match(messageRowSource, /message\.state === "streaming" && <AgentActivityStatus state="composing"/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /satisfies Record<OrbState, string>/);
assert.doesNotMatch(globalStyles, /\.thinking\b/);
});
test("keeps the suggestion row height stable while an answer streams", () => {
// Given: a completed answer already supplies follow-up suggestions.
const suggestionBlock = pageSource.match(/\{activeSuggestions\.length > 0[\s\S]*?<div className="composer-suggestions"[\s\S]*?<\/div>\n\s*\)\}/);
+28 -8
View File
@@ -100,22 +100,24 @@ test("homepage birth-time card starts the first rectification turn without a sec
assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/);
});
test("homepage birth-time card waits for the first turn before opening its dedicated session", () => {
test("homepage birth-time card opens its dedicated session before the first turn resolves", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")');
const request = handler.indexOf("sendConversationalRectificationCommand");
const reveal = handler.indexOf("setActiveSessionId(boundSession.id)");
const firstAwait = handler.indexOf("await ");
const reveal = handler.indexOf("setActiveSessionId(rectificationSession.id)");
assert.ok(create >= 0);
assert.ok(request > create);
assert.ok(reveal > request);
assert.doesNotMatch(handler.slice(0, request), /setActiveSessionId\(/);
assert.doesNotMatch(handler.slice(0, request), /setSessions\(/);
assert.ok(firstAwait > create);
assert.ok(reveal > create && reveal < firstAwait);
assert.ok(handler.indexOf("setSessions((current) => [") < firstAwait);
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
assert.match(handler, /setRectificationInitialTurn\(turn\);[\s\S]*?setActiveSessionId\(boundSession\.id\)/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -245,3 +247,21 @@ test("homepage entrypoints use two whole-card native actions", () => {
assert.equal(wholeCardActions.length, 2);
assert.doesNotMatch(source, /className="daily-starlanguage-heading">[\s\S]{0,180}<button/);
});
test("starter homepage stays editorial and hides technical chart parameters", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const start = source.indexOf('<div className="starter-list starter-workbench"');
const end = source.indexOf("{onboardingError &&", start);
const starterHomepage = source.slice(start, end);
assert.ok(start >= 0 && end > start);
assert.match(starterHomepage, /className="starter-hero"/);
assert.match(starterHomepage, /className="starter-theme-accordion"/);
assert.match(starterHomepage, /starterSuggestions\.map/);
assert.doesNotMatch(starterHomepage, /evidencePreview|birthTimeDisplay|Vimshottari|D1|D9/);
assert.match(source, /const starterSuggestions = themes\.map/);
assert.match(source, /composer-wrap-starter/);
assert.match(styles, /\/\* Starter workbench \*\/[\s\S]*?\.starter-list \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/);
assert.match(styles, /\.starter-hero,[\s\S]*?\.product-entrypoints,[\s\S]*?\.starter-themes \{[\s\S]*?width: 100%;/);
});
@@ -72,6 +72,42 @@ test("never invents a missing month or day", () => {
assert.equal(evidence?.eventSummary, "毕业");
});
test("accepts nineteenth-century Chinese and ISO dates as scoreable historical evidence", () => {
const [education] = extractLifeEventEvidence({
rawText: "1891年11月进入巴黎大学学习",
sourceTurnId,
asOfDate: "2026-07-20",
});
const [relationship] = extractLifeEventEvidence({
rawText: "1895-07-26结婚",
sourceTurnId,
asOfDate: "2026-07-20",
});
assert.deepEqual(
[education?.dateValue, education?.domain, education?.scoreable],
["1891-11", "education", true],
);
assert.deepEqual(
[relationship?.dateValue, relationship?.domain, relationship?.scoreable],
["1895-07-26", "relationship", true],
);
});
for (const rawText of [
"2003年确诊癌症并接受手术",
"2006年丈夫因交通事故去世",
]) {
test(`classifies dated health, accident, and bereavement evidence for D30 scoring: ${rawText}`, () => {
const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" });
assert.ok(evidence.length > 0);
assert.ok(evidence.every((item) => item.domain === "health_pressure"));
assert.ok(evidence.every((item) => item.scoreable));
assert.ok(evidence.every((item) => lifeEventEvidenceSchema.safeParse(item).success));
});
}
test("classifies dated income and asset changes as finance evidence", () => {
const [evidence] = extractLifeEventEvidence({
rawText: "2022年8月收入大幅增加并开始投资",
@@ -60,10 +60,8 @@ function richOutput(): RectificationNarrativeModelOutput {
const packet = syntheticTechnicalPacket();
return {
narrative: [
"05:20 是 05:1605:24 范围内的待验证候选,不是已经确认的出生分钟。",
"D1 上升在范围内保持 Cancer,属于稳定层【consult-d1-ascendant】;D9 与 D10 分别出现 Leo/Virgo、Libra/Scorpio 的分钟敏感变化。",
"因此关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么。",
"未来窗口只能作为背景,不能计入既成事件评分。",
"当前仍在核对 05:1605:24 的候选范围,还不能把其中某一分钟当作确定出生时间。",
"先说一件已经发生的重要关系经历好吗?尽量写明哪一年、哪一月以及发生了什么。",
].join("\n"),
candidateStatus: "pending_validation",
representativeTime: "05:20",
@@ -78,7 +76,7 @@ function richOutput(): RectificationNarrativeModelOutput {
{ domain: "career", layer: "D10", reason: packet.suggestedDomains[1]?.reason ?? "" },
],
evidenceRequest: {
domains: ["relationship", "career"],
domains: ["relationship"],
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实关系或事业事件,并尽量说明哪一年、哪一月以及发生了什么。",
},
@@ -114,10 +112,9 @@ test("validates a rich first-turn narrative against the technical packet", async
assert.equal(result.allowEvidenceScoringAdvance, true);
assert.equal(validationReceiptSchema.safeParse(result.validationReceipt).success, true);
assert.equal(result.output.candidateStatus, "pending_validation");
assert.match(result.narrative, /待验证候选/);
assert.match(result.narrative, /D1/);
assert.match(result.narrative, /D9[\s\S]*D10/);
assert.match(result.narrative, /关系[\s\S]*D9[\s\S]*事业[\s\S]*D10/);
assert.match(result.narrative, /05:1605:24[\s\S]*不能/);
assert.doesNotMatch(result.narrative, /\bD\d+\b/);
assert.equal((result.narrative.match(/[?]/g) ?? []).length, 1);
assert.match(result.narrative, /已经发生[\s\S]*哪一年[\s\S]*哪一月/);
assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/);
});
@@ -138,24 +135,18 @@ test("rejects invented representative times, layers, and references", () => {
assert.ok(result.issues.some((issue) => issue.includes("invented-reference")));
});
test("rejects a first turn that names layer tokens without their stable and sensitive values", () => {
test("accepts a concise first turn without exposing technical layer values", () => {
const packet = syntheticTechnicalPacket();
const invalid = {
...richOutput(),
narrative: [
"05:20 是 05:1605:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
"D1 是稳定层,D9 和 D10 是分钟敏感层。",
"关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,写明哪一年、哪一月。",
].join("\n"),
narrative: "当前仍在核对 05:16–05:24 的候选范围,不能视为已经确认的出生分钟。先说一件过去的重要关系经历好吗?请写明哪一年、哪一月。",
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
assert.equal(result.valid, false);
assert.ok(result.issues.some((issue) => issue.includes("stable evidence semantics")));
assert.ok(result.issues.some((issue) => issue.includes("sensitive evidence semantics")));
assert.deepEqual(result, { valid: true, issues: [] });
});
test("rejects duplicated generic domain reasons that omit a packet discrimination pair", () => {
test("rejects duplicated generic domain reasons that are not packet-grounded", () => {
const packet = syntheticTechnicalPacket();
const relationshipReason = richOutput().domainReasons[0];
assert.ok(relationshipReason);
@@ -169,7 +160,7 @@ test("rejects duplicated generic domain reasons that omit a packet discriminatio
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
assert.equal(result.valid, false);
assert.ok(result.issues.some((issue) => issue.includes("packet discrimination pairs")));
assert.ok(result.issues.some((issue) => issue.includes("packet discrimination explanation")));
});
test("rejects a generic broad-year choice questionnaire and uses a safe scoring-compatible fallback", async () => {
@@ -320,7 +311,7 @@ test("retries expression exactly once with the same grounded packet", async () =
assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false);
});
test("uses a deterministic rich Chinese fallback after the second mismatch without blocking scoring", async () => {
test("uses a deterministic one-question conversational fallback after the second mismatch", async () => {
const packet = syntheticTechnicalPacket();
const invalid = { ...richOutput(), sensitiveLayers: ["D60"] };
const first = await generateRectificationNarrative({
@@ -338,11 +329,11 @@ test("uses a deterministic rich Chinese fallback after the second mismatch witho
assert.equal(first.fallbackUsed, true);
assert.equal(first.allowEvidenceScoringAdvance, true);
assert.equal(first.narrative, second.narrative);
assert.match(first.narrative, /05:20[\s\S]*待验证/);
assert.match(first.narrative, /D1[\s\S]*稳定/);
assert.match(first.narrative, /D9[\s\S]*D10[\s\S]*敏感/);
assert.match(first.narrative, /05:1605:24[\s\S]*不能/);
assert.doesNotMatch(first.narrative, /\bD\d+\b/);
assert.match(first.narrative, /已经发生[\s\S]*年[\s\S]*月/);
assert.match(first.narrative, /未来[\s\S]*不能[\s\S]*评分/);
assert.equal((first.narrative.match(/[?]/g) ?? []).length, 1);
assert.deepEqual(first.output.evidenceRequest?.domains, ["relationship"]);
});
test("bounds fallback validation issues for the durable receipt", async () => {
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { resolve } from "node:path";
import { extractLifeEventEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts";
type ReplayManifest = {
readonly cases: readonly {
readonly case_id: string;
readonly disclosure_order: readonly {
readonly event_id: string;
readonly user_utterance: string;
readonly expected_extraction: {
readonly date_value: string | null;
readonly date_precision: "day" | "month" | "year" | "unknown";
readonly domain: string;
readonly scoreable: boolean;
};
}[];
}[];
};
const manifestPath = resolve(
process.cwd(),
"../references/real_case_calibration/conversational_rectification_development_v1.json",
);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ReplayManifest;
for (const replayCase of manifest.cases) {
for (const [index, disclosure] of replayCase.disclosure_order.entries()) {
test(`extracts public replay utterance ${replayCase.case_id}/${disclosure.event_id}`, () => {
const evidence = extractLifeEventEvidence({
rawText: disclosure.user_utterance,
sourceTurnId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
asOfDate: "2026-07-22",
});
assert.equal(evidence.length, 1);
assert.equal(evidence[0]?.dateValue, disclosure.expected_extraction.date_value);
assert.equal(evidence[0]?.datePrecision, disclosure.expected_extraction.date_precision);
assert.equal(evidence[0]?.domain, disclosure.expected_extraction.domain);
assert.equal(evidence[0]?.scoreable, disclosure.expected_extraction.scoreable);
});
}
}
@@ -85,28 +85,21 @@ function controller(overrides: Partial<ConversationalRectificationController> =
};
}
test("rich narrative precedes 24 domain choices while free text remains available", () => {
test("rectification is a language-first exchange with one free-text answer path", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
));
assert.match(markup, /<h2>当前判断<\/h2>/);
assert.match(markup, /<strong>05:18<\/strong>/);
assert.ok(markup.indexOf("候选时间") < markup.indexOf("当前判断"));
assert.ok(markup.indexOf("当前判断") < markup.indexOf('data-evidence-domain="relationship"'));
assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3);
assert.match(markup, /aria-label="重要关系,下一步建议"/);
assert.match(markup, /aria-label="事业与身份,已提供,可继续补充"/);
assert.ok(
markup.indexOf('data-evidence-domain="relationship"')
< markup.indexOf('data-evidence-domain="career"'),
);
assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/);
assert.match(markup, /目前已经形成一个待确认候选/);
assert.ok(markup.indexOf("待确认候选") < markup.indexOf("当前候选 05:18"));
assert.doesNotMatch(markup, /D9 与 D10|当前判断/);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /aria-label="经历发生年份"/);
assert.match(markup, /aria-label="经历发生月份"/);
assert.match(markup, /<option value=""[^>]*>不确定<\/option>/);
assert.match(markup, /Ctrl\/⌘ \+ Enter/);
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/);
});
@@ -125,7 +118,7 @@ test("a resumed legacy turn replaces repeated technical prose with actionable gu
assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/);
assert.match(markup, /范围暂未变化不代表提交失败/);
assert.match(markup, /下一步:请优先补充一件重要关系或搬迁与居住地领域/);
assert.match(markup, /接下来请说一件重要关系或搬迁与居住地方面已经发生的事/);
assert.doesNotMatch(markup, /D1 保持稳定/);
});
@@ -162,12 +155,13 @@ test("evidence is correctable, secondary controls stay hidden, and confirmation
assert.match(markup, /更正这条经历:开始第一份长期工作/);
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
assert.match(markup, /待确认 · 未验证/);
assert.match(markup, /确认将 05:18 设为当前排盘时间/);
assert.match(markup, /当前候选 05:18/);
assert.match(markup, /确认,尚未验证/);
assert.match(markup, /确认采用 05:18(尚未验证)/);
assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/);
assert.match(markup, /已记录 1 条经历/);
assert.match(markup, /至少需要 3 条/);
assert.match(markup, /一步优先补充/);
assert.match(markup, /不会自动采用/);
assert.match(markup, /候选只用于继续验证/);
assert.match(markup, /一步不会自动采用候选/);
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
});
@@ -196,9 +190,8 @@ test("correction mode identifies its durable target, can be cancelled, and marks
assert.match(markup, /正在更正/);
assert.match(markup, /开始第一份长期工作/);
assert.match(markup, /一次只更正一条事件/);
assert.match(markup, /取消更正/);
assert.match(markup, /已修订/);
assert.match(markup, /已修订/);
});
test("pending markup and responsive CSS expose accessibility contracts", () => {
@@ -223,28 +216,33 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
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");
assert.match(markup, /aria-busy="true"/);
assert.match(markup, /aria-label="正在核对经历"/);
assert.match(markup, /Jyotisha 正在核对经历/);
assert.match(markup, /正在核对这段经历/);
assert.match(markup, /app-loading-orbit/);
assert.match(markup, /Jyotisha 正在分析/);
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
assert.match(markup, /aria-label="生时校正对话"/);
assert.match(markup, /role="alert"|aria-live="polite"/);
assert.match(css, /\.conversational-rectification[\s\S]*min-width:\s*0/);
assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/);
assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/);
assert.match(css, /\.conversational-rectification[^}]*:focus-visible/);
assert.match(css, /:where\(button, textarea\):focus-visible/);
assert.match(css, /\.conversational-answer-pending[\s\S]*grid-template-columns:\s*40px minmax\(0, 1fr\)/);
assert.match(css, /\.conversational-domain-picker button\[aria-pressed="true"\]/);
assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/);
assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/);
assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/);
assert.match(css, /\.rectification-message-details button[^}]*min-height:\s*44px/);
assert.match(css, /\.composer:focus-within[^}]*border-color:/);
assert.match(css, /\.composer textarea[^}]*border:\s*0/);
assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/);
assert.match(page, /rectificationSurfaceOpen \? "is-rectification"/);
assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/);
assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/);
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.rectification-message-details/);
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
assert.match(component, /controller\.answer\(undefined, controller\.draft\.trim\(\)\)/);
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(component, /onPendingChange/);
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
assert.match(component, /&& onContinueOriginalQuestion &&/);
assert.match(component, /onContinueOriginalQuestion\?\./);
});
type CdpResponse = Readonly<{
@@ -727,7 +725,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
await waitFor(
() => cdp?.evaluate<boolean>(`document.querySelector('.conversational-candidate time')?.textContent === '05:18'
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('当前候选 05:18')
&& document.body.textContent.includes('已记录:2021-07')`) ?? Promise.resolve(false),
"streamlined async initial turn",
);
@@ -737,25 +735,28 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
scrollWidth: number;
surfaceWidth: number;
shortestButton: number;
dateFieldsShareRow: boolean;
selectCount: number;
domainChoiceCount: number;
}>(`(() => {
const buttons = [...document.querySelectorAll('.conversational-rectification button')];
const year = document.querySelector('[aria-label="经历发生年份"]').getBoundingClientRect();
const month = document.querySelector('[aria-label="经历发生月份"]').getBoundingClientRect();
const buttons = [...document.querySelectorAll('.rectification-chat button')]
.filter((button) => button.getBoundingClientRect().height > 0);
return {
viewport: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width,
surfaceWidth: document.querySelector('.rectification-chat').getBoundingClientRect().width,
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
dateFieldsShareRow: Math.abs(year.top - month.top) < 2,
selectCount: document.querySelectorAll('.rectification-chat select').length,
domainChoiceCount: document.querySelectorAll('[data-evidence-domain]').length,
};
})()`);
assert.equal(layout.viewport, 390);
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
assert.equal(layout.dateFieldsShareRow, true, "year and month should stay on one row at 390px");
assert.equal(layout.selectCount, 0, "language-first flow should not render date selects");
assert.equal(layout.domainChoiceCount, 0, "language-first flow should not render domain buttons");
await cdp.evaluate("document.querySelector('.rectification-message-details').open = true");
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
@@ -772,17 +773,12 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
"correction cancellation",
);
await cdp.evaluate("document.querySelector('[data-evidence-domain=career]').click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer'") ?? Promise.resolve(false),
"domain-to-composer focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const text = document.body.textContent;
return text.includes('候选时间')
return text.includes('当前候选')
&& !text.includes('候选时间')
&& !text.includes('本轮技术回执')
&& !text.includes('暂停,稍后继续')
&& !text.includes('放弃本次校正')
@@ -2,13 +2,27 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const migration = readFileSync(
const financeMigration = readFileSync(
new URL(
"../supabase/migrations/20260721150000_align_conversational_finance_domain.sql",
import.meta.url,
),
"utf8",
);
const healthPressureMigration = readFileSync(
new URL(
"../supabase/migrations/20260722170000_align_conversational_health_pressure_domain.sql",
import.meta.url,
),
"utf8",
);
const requestCardinalityMigration = readFileSync(
new URL(
"../supabase/migrations/20260722180000_align_conversational_evidence_request_cardinality.sql",
import.meta.url,
),
"utf8",
);
test("durable rectification SQL accepts every application evidence domain", () => {
for (const validator of [
@@ -16,17 +30,46 @@ test("durable rectification SQL accepts every application evidence domain", () =
"conversational_rectification_valid_life_event_evidence(jsonb)",
"conversational_rectification_valid_private_candidate(jsonb)",
]) {
assert.match(migration, new RegExp(validator.replace(/[()]/g, "\\$&")));
assert.match(financeMigration, new RegExp(validator.replace(/[()]/g, "\\$&")));
}
assert.match(migration, /'education', 'finance', 'relocation'/);
assert.match(migration, /birth_time_rectification_event_evidence_domain_check/);
assert.match(financeMigration, /'education', 'finance', 'relocation'/);
assert.match(financeMigration, /birth_time_rectification_event_evidence_domain_check/);
});
test("durable public recap accepts and validates its optional domain", () => {
assert.match(
migration,
financeMigration,
/array\['id', 'summary', 'dateLabel', 'domain', 'isCorrection'\]/,
);
assert.match(migration, /item \? 'domain'/);
assert.match(migration, /item ->> 'domain' not in/);
assert.match(financeMigration, /item \? 'domain'/);
assert.match(financeMigration, /item ->> 'domain' not in/);
});
test("durable public turns accept health pressure follow-up requests", () => {
for (const validator of [
"conversational_rectification_valid_evidence_request(jsonb)",
"conversational_rectification_valid_evidence_recap(jsonb)",
"conversational_rectification_valid_life_event_evidence(jsonb)",
"conversational_rectification_valid_private_candidate(jsonb)",
]) {
assert.match(
healthPressureMigration,
new RegExp(validator.replace(/[()]/g, "\\$&")),
);
}
assert.match(healthPressureMigration, /'finance'', ''health_pressure'', ''relocation'/);
assert.match(
healthPressureMigration,
/'finance', 'health_pressure', 'relocation'/,
);
assert.match(healthPressureMigration, /birth_time_rectification_event_evidence_domain_check/);
});
test("durable evidence requests allow one focused follow-up domain", () => {
assert.match(
requestCardinalityMigration,
/conversational_rectification_valid_evidence_request\(jsonb\)/,
);
assert.match(requestCardinalityMigration, /'between 2 and 4'/);
assert.match(requestCardinalityMigration, /'between 1 and 4'/);
});
@@ -143,15 +143,11 @@ function narrativeGenerator() {
};
const packet = request.packet;
const final = request.phase === "final";
const nextDomain = packet.suggestedDomains[0]?.domain ?? "career";
return { text: JSON.stringify({
narrative: [
`${packet.candidate.representativeTime} 是待验证候选,范围为 ${packet.candidate.rangeStart}${packet.candidate.rangeEnd}`,
"D1 的 Cancer 在范围内保持稳定。",
"D9 的 Aries / Leo 存在分钟敏感差异,搬迁事件可以区分 D9。",
"D10 的 Taurus / Libra 存在分钟敏感差异,事业事件可以区分 D10。",
"D24 的 Gemini / Virgo 存在分钟敏感差异,学业事件可以区分 D24。",
final ? "现有已发生事件支持进入候选确认。" : "请写一件已经发生的真实事件,注明哪一年、哪一月以及发生了什么。",
packet.useBoundary,
`当前仍在核对 ${packet.candidate.rangeStart}${packet.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟`,
final ? "现有已发生事件支持进入候选确认。" : "先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
].join(""),
candidateStatus: packet.candidate.status,
representativeTime: packet.candidate.representativeTime,
@@ -163,7 +159,7 @@ function narrativeGenerator() {
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
evidenceRequest: final ? null : {
domains: packet.suggestedDomains.map((item) => item.domain),
domains: [nextDomain],
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
},
@@ -472,12 +468,11 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
let turn = await post(handler, { type: "start", actionId: caseId, pendingConsultationQuestion: originalQuestion });
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
assert.equal(turn.status, "active");
assert.match(turn.narrative, /05:30.*待验证候选/);
assert.match(turn.narrative, /D1.*稳定/);
assert.match(turn.narrative, /D9.*敏感差异/);
assert.match(turn.narrative, /D10.*敏感差异/);
assert.match(turn.narrative, /05:0006:00.*不能/);
assert.doesNotMatch(turn.narrative, /\bD\d+\b/);
assert.equal((turn.narrative.match(/[?]/g) ?? []).length, 1);
assert.match(turn.narrative, /哪一年、哪一月/);
assert.deepEqual(turn.evidenceRequest?.domains, ["career", "education", "relocation"]);
assert.deepEqual(turn.evidenceRequest?.domains, ["career"]);
assert.equal(turn.evidenceRequest?.freeTextAllowed, true);
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
@@ -120,15 +120,12 @@ function validGenerator(
};
};
const value = request.packet;
const domains = value.suggestedDomains.map((item) => item.domain);
const domains = value.suggestedDomains.slice(0, 1).map((item) => item.domain);
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
const narrative = [
`${value.candidate.representativeTime} 是待验证候选`,
"D1Cancer)保持稳定。",
"D9Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。",
"D10Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。",
`当前仍在核对 ${value.candidate.rangeStart}${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟`,
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。",
"这仅是候选,必须由你确认后才会替换当前排盘时间。",
request.phase === "final" ? "当前证据已形成候选总结。" : `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
].join("");
return { text: JSON.stringify({
narrative,
@@ -144,7 +141,7 @@ function validGenerator(
evidenceRequest: request.phase === "final" ? null : {
domains,
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
prompt: `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`,
},
}) };
},
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildProductionConversationalRectificationPacket,
@@ -20,6 +21,15 @@ const actionId = "00000000-0000-4000-8000-000000000712";
const caseId = "00000000-0000-4000-8000-000000000713";
const requestId = "00000000-0000-4000-8000-000000000714";
test("production narrator loads the Jyotish Skill without overriding packet truth", () => {
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");
assert.match(source, /skills:\s*\[jyotishSkillPath\]/);
assert.match(source, /Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording/);
assert.match(source, /supplied packet facts as the exclusive source/);
assert.match(source, /Never invent, recalculate, or confirm candidate data/);
});
const turn = {
caseId,
journeyProtocol: "conversational-evidence-v3" as const,
@@ -139,7 +149,7 @@ function packetEngine(options: {
},
async score() { throw new Error("unexpected questionnaire score"); },
async scoreEvents(input) {
assert.ok(input.events.length >= 3 && input.events.length <= 6);
assert.ok(input.events.length >= 3 && input.events.length <= 8);
for (const event of input.events) {
const birthBoundary = event.precision === "year"
? input.birthDate.slice(0, 4)
@@ -886,11 +896,11 @@ test("production rescans the declared range after correction while ordinary evid
assert.equal(ordinary.packet.sensitivityScope.sampleTimes.includes("04:50"), false);
});
test("production packet deterministically sends only the latest six supported events", async () => {
test("production packet sends health evidence and uses the shared eight-event convergence limit", async () => {
const scoreCalls: LifeEvent[][] = [];
const differenceCalls: DifferencePacketInput[] = [];
const engine = packetEngine({ scoreCalls, differenceCalls });
const domains = ["education", "relocation", "career", "relationship"] as const;
const domains = ["education", "relocation", "career", "relationship", "health_pressure"] as const;
const evidence = Array.from({ length: 8 }, (_, index) =>
syntheticEvidence(index + 1, domains[index % domains.length] ?? "career"));
@@ -914,12 +924,13 @@ test("production packet deterministically sends only the latest six supported ev
assert.equal(scoreCalls.length, 1);
assert.deepEqual(
scoreCalls[0]?.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
evidence.map((item) => item.id),
);
assert.deepEqual(
differenceCalls.at(-1)?.events.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
evidence.map((item) => item.id),
);
assert.ok(scoreCalls[0]?.some((event) => event.domain === "health_pressure"));
});
test("persistable future background evidence never reaches the production scorer", async () => {
+6 -11
View File
@@ -1,18 +1,13 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const panelSource = readFileSync(new URL("../src/components/parameter-freeze-panel.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const panelPath = new URL("../src/components/parameter-freeze-panel.tsx", import.meta.url);
test("starter workbench shows a parameter freeze panel when profile is complete", () => {
assert.match(pageSource, /ParameterFreezePanel/);
assert.match(pageSource, /parameterFreezeRows/);
assert.match(pageSource, /Ayanamsa/);
assert.match(pageSource, /Node mode/);
assert.match(pageSource, /出生时间精度/);
assert.match(panelSource, /当前排盘参数冻结/);
assert.match(panelSource, /这些参数会随咨询一起送入证据链/);
assert.match(globalStyles, /\.parameter-freeze-panel/);
test("starter workbench hides internal chart parameters from users", () => {
assert.doesNotMatch(pageSource, /ParameterFreezePanel|parameterFreezeRows|当前排盘参数/);
assert.doesNotMatch(globalStyles, /\.parameter-freeze-panel/);
assert.equal(existsSync(panelPath), false);
});
+8
View File
@@ -257,6 +257,14 @@ test("makes SidebarContent the only sidebar scroll owner", () => {
assert.match(globalStyles, /\[data-active="true"\][^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
});
test("renders each session title and menu as one unified row surface", () => {
assert.match(cssBlock(".session-row"), /grid-template-columns:\s*minmax\(0,\s*1fr\)\s+44px/);
assert.match(globalStyles, /\.session-row:has\(\.session-main\[data-active="true"\]\)[^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
assert.match(cssBlock('.session-main[data-active="true"]'), /background:\s*transparent/);
assert.match(cssBlock(".session-menu-trigger"), /border-radius:\s*0/);
assert.doesNotMatch(cssBlock(".session-menu-trigger"), /border-radius:\s*50%/);
});
test("styles the non-mobile collapsed rail without repeated session rows", () => {
assert.match(globalStyles, /@media\s*\(min-width:\s*768px\)[\s\S]*\[data-state="collapsed"\][^{]*\.brand-row/);
assert.match(globalStyles, /\[data-state="collapsed"\][^{]*\[data-sidebar="menu-button"\][^{]*\{[^}]*width:\s*44px/);