merge: land conversational rectification on main
This commit is contained in:
@@ -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: "出生资料已保存,正在为你准备首页。"/);
|
||||
});
|
||||
|
||||
@@ -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*\)\}/);
|
||||
|
||||
@@ -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:16–05:24 范围内的待验证候选,不是已经确认的出生分钟。",
|
||||
"D1 上升在范围内保持 Cancer,属于稳定层【consult-d1-ascendant】;D9 与 D10 分别出现 Leo/Virgo、Libra/Scorpio 的分钟敏感变化。",
|
||||
"因此关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么。",
|
||||
"未来窗口只能作为背景,不能计入既成事件评分。",
|
||||
"当前仍在核对 05:16–05: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:16–05: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:16–05: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:16–05: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 2–4 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:00–06: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} 是待验证候选。`,
|
||||
"D1(Cancer)保持稳定。",
|
||||
"D9(Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。",
|
||||
"D10(Taurus / 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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user