feat: streamline conversational birth-time rectification

This commit is contained in:
Jesse_Chen
2026-07-22 17:39:11 +08:00
parent d40c16369d
commit 3069a7c8a0
30 changed files with 2059 additions and 590 deletions
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
conversationalRectificationCommandSchema,
type ConversationalRectificationCommand,
@@ -25,6 +26,7 @@ import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../.
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts";
import { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts";
import { MAXIMUM_SCOREABLE_EVENTS } from "../../../lib/conversational-rectification/convergence.ts";
import {
conversationalRectificationLatencyBucket,
createConversationalRectificationTelemetry,
@@ -37,6 +39,9 @@ import {
export const runtime = "nodejs";
export const maxDuration = 60;
const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
type AuthenticatedRequest = Readonly<{
userId: string;
context: unknown;
@@ -349,7 +354,7 @@ function scoreableLifeEvents(
precision: item.datePrecision as "day" | "month" | "year",
date: item.dateValue,
} as LifeEvent];
}).slice(-6);
}).slice(-MAXIMUM_SCOREABLE_EVENTS);
}
function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] {
@@ -665,7 +670,8 @@ async function productionNarrativeGenerator(): Promise<RectificationNarrativeGen
id: `conversational-rectification-${model.id}`,
name: "Conversational Rectification Narrator",
model: model.model,
instructions: "Return only the exact JSON object requested by the user prompt. Use only supplied packet facts. Never invent times, layers, references, scores, dates, or confirmation state.",
skills: [jyotishSkillPath],
instructions: "Return only the exact JSON object requested by the user prompt. Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data.",
});
return {
modelId: model.id,
+28 -14
View File
@@ -293,17 +293,19 @@ button:disabled { cursor: default; opacity: .45; }
.sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
.session-nav-toggle { min-height: 44px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; }
.session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); transition: background-color 120ms ease-out, color 120ms ease-out; }
.session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-main { position: relative; width: 100%; min-height: 52px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: var(--sidebar-accent); }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; }
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); }
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-body-sm); font-weight: 500; }
.session-title > svg { width: var(--space-3); height: var(--space-3); flex: 0 0 auto; color: var(--sidebar-ring); }
.session-main small { color: inherit; line-height: 1.3; opacity: .72; font-size: var(--type-overline); }
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, color 120ms ease-out; }
.session-menu-trigger > svg { width: 18px; height: 18px; }
.session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; }
.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: transparent; color: inherit; }
.session-actions { position: absolute; z-index: 4; top: calc(100% - var(--space-1)); right: 0; min-width: calc(var(--space-24) + var(--space-12)); display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
.session-actions button { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font-size: var(--type-body-sm); }
.session-actions button > svg { width: 16px; height: 16px; }
@@ -464,13 +466,6 @@ button:disabled { cursor: default; opacity: .45; }
.conversational-narrative .message-markdown > :first-child { margin-top: 0; }
.conversational-narrative .message-markdown > :last-child { margin-bottom: 0; }
.conversational-narrative .message-markdown h2, .conversational-narrative .message-markdown h3 { margin: var(--space-4) 0 var(--space-2); font-family: var(--font-sans); font-size: var(--type-body-md); font-weight: 600; letter-spacing: 0; }
.conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; }
.conversational-domain-picker .conversational-domain-question { margin-bottom: var(--space-2); color: var(--color-ink); font-size: var(--type-body-md); font-weight: 600; line-height: 1.5; }
.conversational-domain-picker > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-2); }
.conversational-domain-picker button { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; }
.conversational-domain-picker button > span { font-size: var(--type-body-sm); font-weight: 600; }
.conversational-domain-picker button > small { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 400; }
.conversational-domain-picker button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); }
.conversational-answer-pending { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid color-mix(in srgb, var(--color-action) 24%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-action-soft); }
.conversational-answer-pending .app-loading-symbol { width: 40px; height: 40px; }
.conversational-answer-pending .app-loading-mark { width: 22px; height: 22px; }
@@ -480,8 +475,6 @@ button:disabled { cursor: default; opacity: .45; }
.conversational-composer { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.conversational-composer label { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); font-weight: 600; }
.conversational-composer label span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; line-height: 1.5; }
.conversational-event-date { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.conversational-event-date label { gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 500; }
.conversational-composer textarea { width: 100%; min-width: 0; min-height: 112px; resize: vertical; padding: var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); line-height: 1.55; }
.conversational-composer-footer { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.conversational-composer-footer small { color: var(--color-ink-tertiary); font-size: var(--type-overline); }
@@ -781,10 +774,31 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
[data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; }
.conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; }
.conversational-narrative-body, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { padding: var(--space-3); }
.conversational-domain-picker > div, .conversational-candidate dl { grid-template-columns: minmax(0, 1fr); }
.conversational-candidate dl { grid-template-columns: minmax(0, 1fr); }
.conversational-composer-footer { align-items: stretch; flex-direction: column; }
.conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; }
}
/* Conversational rectification shell */
.conversation.is-rectification { overflow: hidden; padding-bottom: 0; }
.rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); }
.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); }
.rectification-message-details { margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-message-details summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; }
.rectification-message-details p { margin: 0 0 var(--space-2); font-size: var(--type-caption); line-height: 1.6; }
.rectification-message-details ul { display: grid; gap: var(--space-2); margin: var(--space-3) 0 0; padding: 0; list-style: none; }
.rectification-message-details li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
.rectification-message-details button, .rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
.rectification-composer-wrap { position: static; flex: 0 0 auto; }
.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-correction-target p { margin: 0; }
.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); }
@media (max-width: 430px) {
.rectification-message-details li { align-items: stretch; grid-template-columns: minmax(0, 1fr); }
.rectification-correction-target { align-items: stretch; display: grid; }
}
/* Starter workbench */
.welcome {
width: min(1040px, 100%);
+44 -22
View File
@@ -836,6 +836,7 @@ export default function Home() {
createDurableRectificationQuestionHandoffClient(),
);
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const rectificationContinuationInFlight = useRef(false);
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
@@ -853,7 +854,8 @@ export default function Home() {
const visibleRectificationTurn = activeSession?.id === rectificationSessionId
? rectificationInitialTurn
: null;
const rectificationSurfaceOpen = activeRectificationSession && visibleRectificationTurn !== null;
const rectificationSurfaceOpen = activeRectificationSession
&& activeSession.id === rectificationSessionId;
const visibleSessions = sessions
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
@@ -1759,7 +1761,7 @@ export default function Home() {
event.preventDefault();
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setBirthTimeAssessmentPhase("saving_profile");
setBirthTimeAssessmentPhase("entering_home");
setAccountError("");
try {
await persistProfile(profileDraft);
@@ -1906,7 +1908,8 @@ export default function Home() {
pendingConsultationQuestion: string | null = null,
sourceSessionOverride: ChatSession | null = null,
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|| rectificationMutationPending
|| rectificationContinuationInFlight.current) return;
const sourceSession = sourceSessionOverride ?? activeSession;
if (!sourceSession) return;
@@ -1936,6 +1939,7 @@ export default function Home() {
? null
: rectificationQuestionHandoff.current.peek()?.question)
?? null;
rectificationOpenInFlight.current = true;
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
@@ -1943,6 +1947,18 @@ export default function Home() {
setRectificationInitialTurn(null);
setRectificationError("");
setRectificationLoading(true);
if (!reusingRectificationSession) {
setSessions((current) => [
rectificationSession,
...current.filter((session) => session.id !== rectificationSession.id),
]);
}
if (sourceSession.id !== rectificationSession.id) {
setRectificationReturnSessionId(sourceSession.id);
}
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
try {
let turn: ConversationalRectificationTurn;
if (action !== "resume" || !account.rectificationCase) {
@@ -2008,12 +2024,6 @@ export default function Home() {
}
setRectificationInitialTurn(turn);
synchronizeRectificationQuestion(turn, sourceSession);
if (sourceSession.id !== boundSession.id) {
setRectificationReturnSessionId(sourceSession.id);
}
setRectificationSessionId(boundSession.id);
activeSessionIdRef.current = boundSession.id;
setActiveSessionId(boundSession.id);
setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : "");
} catch (caught) {
const message = caught instanceof Error
@@ -2021,7 +2031,14 @@ export default function Home() {
: "生时校正暂时无法继续,请稍后重试。";
setRectificationError(message);
setComposerNotice(message);
if (!reusingRectificationSession) {
setSessions((current) => current.filter((session) => session.id !== rectificationSession.id));
setRectificationSessionId(null);
activeSessionIdRef.current = sourceSession.id;
setActiveSessionId(sourceSession.id);
}
} finally {
rectificationOpenInFlight.current = false;
setRectificationLoading(false);
}
}
@@ -2874,7 +2891,7 @@ export default function Home() {
</button>
</header>
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"} ${rectificationSurfaceOpen ? "is-rectification" : ""}`}>
{!rectificationSurfaceOpen && (!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -3049,18 +3066,23 @@ export default function Home() {
<div ref={conversationEnd} />
</div>
))}
{rectificationSurfaceOpen && visibleRectificationTurn && (
<section className="rectification-session-surface" aria-label="生时校正">
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
/>
</section>
)}
{rectificationSurfaceOpen && (!visibleRectificationTurn && rectificationError ? (
<div className="conversational-rectification-start-error" role="alert">
<p>{rectificationError}</p>
<button type="button" disabled={rectificationLoading} onClick={() => void openBirthTimeRectification(null, activeSession ?? null)}>
{rectificationLoading ? "正在重试…" : "重新建立校正记录"}
</button>
</div>
) : (
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
/>
))}
</div>
{!rectificationSurfaceOpen && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
@@ -1,4 +1,4 @@
export type BirthTimeAssessmentPhase = "saving_profile" | "assessing";
export type BirthTimeAssessmentPhase = "saving_profile" | "assessing" | "entering_home";
const progressCopy = {
saving_profile: {
@@ -9,6 +9,10 @@ const progressCopy = {
title: "正在生成生时评估",
detail: "正在读取已有资料并准备下一步,不需要重复操作。",
},
entering_home: {
title: "正在进入首页",
detail: "出生资料已保存,正在为你准备首页。",
},
} as const satisfies Record<BirthTimeAssessmentPhase, { readonly title: string; readonly detail: string }>;
export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTimeAssessmentPhase | null }) {
@@ -1,11 +1,11 @@
"use client";
import {
useEffect,
useRef,
useState,
} from "react";
import { ArrowUp } from "lucide-react";
import { useEffect, useRef } from "react";
import { ChatMessageContent } from "./chat-message-content.tsx";
import { AgentAvatar, ChatMessageRow } from "./chat-message-row.tsx";
import { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import {
useConversationalRectification,
type ConversationalRectificationController,
@@ -20,46 +20,52 @@ const domainLabels = {
career: "事业与身份",
education: "学业与学习",
finance: "收入与资产",
health_pressure: "健康与重大压力",
relocation: "搬迁与居住地",
relationship: "重要关系",
family: "家庭变化",
other: "其他关键经历",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
function orderedEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] {
function nextEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] {
const provided = new Set(turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []));
return [...(turn.evidenceRequest?.domains ?? [])].sort((left, right) =>
Number(provided.has(left)) - Number(provided.has(right)));
return [...(turn.evidenceRequest?.domains ?? [])]
.sort((left, right) => Number(provided.has(left)) - Number(provided.has(right)))
.slice(0, 2);
}
function visibleTurnNarrative(turn: ConversationalRectificationTurn): string {
if (
turn.status !== "active"
|| turn.evidenceRecap.length === 0
|| /(?:已记录|已修订)/.test(turn.narrative)
) {
if (["paused", "abandoned", "completed"].includes(turn.status)) {
return turn.narrative;
}
const latestEvidence = turn.evidenceRecap.at(-1)!;
const candidate = turn.candidate;
const candidateRange = candidate.rangeStart && candidate.rangeEnd
? `${candidate.rangeStart}${candidate.rangeEnd}`
: candidate.representativeTime ?? "当前候选范围";
const nextDomains = orderedEvidenceDomains(turn)
.slice(0, 2)
.map((domain) => domainLabels[domain]);
const nextStep = candidate.status === "ready_for_confirmation"
? "当前证据已形成候选总结;请先核对上方候选时间,再决定是否确认或继续补充经历。"
: nextDomains.length > 0
? `下一步:请优先补充一件${nextDomains.join("或")}领域已经发生的事件,并选择发生年月。`
: "下一步:请继续补充一件已经发生并带有年月的真实经历。";
const suggestedDomains = nextEvidenceDomains(turn).map((domain) => domainLabels[domain]);
if (turn.evidenceRecap.length === 0) {
const examples = suggestedDomains.length > 0
? suggestedDomains.join("或")
: "工作、搬迁、关系或学业";
return `为了帮助校正出生时间,请先说一件${examples}方面已经发生的重要经历。最好带上年月,直接像聊天一样描述即可。`;
}
const latestEvidence = turn.evidenceRecap.at(-1)!;
if (turn.candidate.status === "ready_for_confirmation") {
return [
`${latestEvidence.isCorrection ? "已修订" : "已记录"}${latestEvidence.dateLabel} · ${latestEvidence.summary}`,
"目前已经形成一个待确认候选。你可以展开下方详情核对,也可以继续补充一件带年月的真实经历。",
].join("\n\n");
}
const candidateRange = turn.candidate.rangeStart && turn.candidate.rangeEnd
? `${turn.candidate.rangeStart}${turn.candidate.rangeEnd}`
: turn.candidate.representativeTime ?? "当前候选范围";
const nextQuestion = suggestedDomains.length > 0
? `接下来请说一件${suggestedDomains.join("或")}方面已经发生的事,尽量带上年月。`
: "接下来请再说一件已经发生的真实经历,尽量带上年月。";
return [
`${latestEvidence.isCorrection ? "已修订" : "已记录"}${latestEvidence.dateLabel} · ${latestEvidence.summary}`,
`候选范围当前为 ${candidateRange}范围暂未变化不代表提交失败,系统会结合后续经历继续比较相邻分钟。`,
nextStep,
].join("\n");
`候选范围现在是 ${candidateRange}范围暂未变化不代表提交失败,会结合后续经历继续比较相邻分钟。`,
nextQuestion,
].join("\n\n");
}
type SurfaceProps = Readonly<{
@@ -73,53 +79,11 @@ function safely(request: Promise<unknown>) {
void request.catch(() => undefined);
}
function CandidateSummary({ turn }: { readonly turn: ConversationalRectificationTurn }) {
const candidate = turn.candidate;
const confirmed = candidate.status === "confirmed" && turn.status === "completed";
const status = confirmed
? "已明确确认"
: turn.status === "completed"
? "范围已保存 · 分钟未确认"
: candidate.status === "ready_for_confirmation"
? "待确认 · 未验证"
: "待验证 · 未确认";
const recordedCount = turn.evidenceRecap.length;
const nextDomains = orderedEvidenceDomains(turn)
.slice(0, 2)
.map((domain) => domainLabels[domain]);
return (
<section className="conversational-candidate" aria-labelledby="conversational-candidate-title">
<header>
<h3 id="conversational-candidate-title"></h3>
<span className={confirmed ? "is-confirmed" : "is-unverified"}>{status}</span>
</header>
{candidate.representativeTime ? (
<dl>
<div>
<dt></dt>
<dd><time dateTime={candidate.representativeTime}>{candidate.representativeTime}</time></dd>
</div>
<div>
<dt></dt>
<dd>{candidate.rangeStart && candidate.rangeEnd
? `${candidate.rangeStart}${candidate.rangeEnd}`
: "尚未缩小"}</dd>
</div>
</dl>
) : <p></p>}
{!confirmed && <p></p>}
{!confirmed && recordedCount > 0 && turn.status !== "completed" && (
<div className="conversational-candidate-progress" role="status">
<strong> {recordedCount} </strong>
<span> 3 </span>
{nextDomains.length > 0 && (
<span>{nextDomains.join("或")}</span>
)}
</div>
)}
</section>
);
function candidateStatus(turn: ConversationalRectificationTurn): string {
if (turn.status === "completed" && turn.candidate.status === "confirmed") return "已确认";
if (turn.status === "completed") return "范围已保存,分钟未确认";
if (turn.candidate.status === "ready_for_confirmation") return "待确认,尚未验证";
return "待验证";
}
export function ConversationalRectificationSurface({
@@ -129,14 +93,11 @@ export function ConversationalRectificationSurface({
onContinueOriginalQuestion,
}: SurfaceProps) {
const composer = useRef<HTMLTextAreaElement>(null);
const [eventYear, setEventYear] = useState("");
const [eventMonth, setEventMonth] = useState("");
const currentYear = new Date().getFullYear();
const eventYears = Array.from({ length: 101 }, (_, index) => currentYear - index);
const turn = controller.turn;
const pendingQuestion = turn?.status === "completed"
? turn.pendingConsultationQuestion
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
if (!turn) {
return (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
@@ -147,262 +108,166 @@ export function ConversationalRectificationSurface({
}
const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed";
const requestedDomains = orderedEvidenceDomains(turn);
const providedDomains = new Set(
turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []),
);
const priorityDomain = requestedDomains.find((domain) => !providedDomains.has(domain))
?? requestedDomains[0]
?? null;
const canConfirm = turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& Boolean(turn.candidate.representativeTime);
const canContinue = turn.actions.includes("continue_original_question")
&& Boolean(pendingQuestion)
&& Boolean(onContinueOriginalQuestion);
const submit = async () => {
if (!canAnswer || !controller.draft.trim() || controller.pending) return;
const dateLabel = eventYear
? `${eventYear}${eventMonth ? ` ${Number(eventMonth)}` : ""}`
: "时间不确定";
const answer = `发生时间:${dateLabel}\n事件详情:${controller.draft.trim()}`;
try {
await controller.answer(undefined, answer);
setEventYear("");
setEventMonth("");
await controller.answer(undefined, controller.draft.trim());
composer.current?.focus();
} catch {
// The controller owns the visible request error and keeps the draft available for retry.
// The controller keeps the draft and owns the visible error.
}
};
const focusComposer = () => composer.current?.focus();
return (
<section
className="conversational-rectification"
aria-busy={controller.pending}
aria-label="生时校正对话"
>
<CandidateSummary turn={turn} />
<article className="conversational-narrative" aria-label="校正分析">
<div className="conversational-narrative-body">
<ChatMessageContent text={visibleTurnNarrative(turn)} />
</div>
</article>
{requestedDomains.length > 0 && (
<fieldset className="conversational-domain-picker">
<legend className="conversational-domain-question"></legend>
<div>
{requestedDomains.map((domain) => (
<button
aria-label={`${domainLabels[domain]}${domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}`}
aria-pressed={controller.selectedDomain === domain}
data-evidence-domain={domain}
disabled={controller.pending || !canAnswer}
key={domain}
type="button"
onClick={() => {
controller.selectDomain(domain);
focusComposer();
}}
>
<span>{domainLabels[domain]}</span>
<small>{domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}</small>
</button>
))}
</div>
</fieldset>
)}
{controller.pending && canAnswer && (
<div
aria-atomic="true"
aria-label="正在核对经历"
aria-live="polite"
className="conversational-answer-pending"
role="status"
>
<div className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</div>
<div>
<strong></strong>
<span></span>
</div>
</div>
)}
{canAnswer && <form
className="conversational-composer"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{controller.correctionTarget && (
<div className="conversational-correction-target" role="status">
<p>
<strong>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</strong>
</p>
<span>使</span>
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => controller.cancelEvidenceCorrection()}
>
</button>
</div>
<section className="rectification-chat" aria-busy={controller.pending} aria-label="生时校正对话">
<div className="message-list rectification-message-list">
<span className="sr-only" aria-live="polite">{controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{turn.evidenceRecap.map((entry) => (
<ChatMessageRow
key={entry.id}
message={{
role: "user",
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
renderKey: entry.id,
state: "settled",
}}
/>
))}
{controller.pending && controller.draft.trim() && (
<ChatMessageRow
message={{ role: "user", text: controller.draft.trim(), renderKey: "pending-evidence", state: "settled" }}
/>
)}
<label htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "填写更正后的真实经历" : "补充真实经历"}
<span></span>
</label>
<div className="conversational-event-date" role="group" aria-label="经历发生时间">
<label>
<select
aria-label="经历发生年份"
disabled={controller.pending || !canAnswer}
value={eventYear}
onChange={(event) => {
setEventYear(event.target.value);
if (!event.target.value) setEventMonth("");
}}
>
<option value=""></option>
{eventYears.map((year) => <option key={year} value={year}>{year} </option>)}
</select>
</label>
<label>
<select
aria-label="经历发生月份"
disabled={controller.pending || !canAnswer || !eventYear}
value={eventMonth}
onChange={(event) => setEventMonth(event.target.value)}
>
<option value=""></option>
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
<option key={month} value={month}>{month} </option>
))}
</select>
</label>
</div>
<textarea
id="conversational-rectification-answer"
disabled={controller.pending || !canAnswer}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:其实是 2020 年 11 月离职"
: "请描述一件已经发生的具体事件"}
ref={composer}
rows={4}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void submit();
}
}}
/>
<div className="conversational-composer-footer">
<small>Ctrl/ + Enter </small>
<button
className="button-primary"
disabled={controller.pending || !canAnswer || !controller.draft.trim()}
type="submit"
>
{controller.pending ? "正在核对…" : "发送这段经历"}
</button>
</div>
</form>}
{turn.evidenceRecap.length > 0 && (
<section className="conversational-evidence-recap" aria-labelledby="conversational-evidence-title">
<header>
<h3 id="conversational-evidence-title"></h3>
<span>{turn.evidenceRecap.length} </span>
</header>
<ul>
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<div>
<time>{entry.dateLabel}</time>
<p>{entry.summary}</p>
{entry.isCorrection && <span className="conversational-correction-badge"></span>}
</div>
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending || !canAnswer}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
focusComposer();
}}
>
</button>
</li>
))}
</ul>
</section>
)}
{turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& turn.candidate.representativeTime && (
<section className="conversational-confirmation" aria-labelledby="conversational-confirmation-title">
<h3 id="conversational-confirmation-title"></h3>
<p></p>
<button
className="button-primary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{controller.pending
? "确认中…"
: `确认将 ${turn.candidate.representativeTime} 设为当前排盘时间`}
</button>
</section>
<article className="message message-assistant" aria-label="Jyotisha">
<AgentAvatar />
<div className="message-content">
<div className="message-bubble">
<ChatMessageContent text={visibleTurnNarrative(turn)} />
<details className="rectification-message-details">
<summary>
{turn.candidate.representativeTime
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
: `校正进度 · ${candidateStatus(turn)}`}
</summary>
<p>
{turn.candidate.rangeStart && turn.candidate.rangeEnd
? `候选范围 ${turn.candidate.rangeStart}${turn.candidate.rangeEnd};已记录 ${turn.evidenceRecap.length} 条经历。`
: `已记录 ${turn.evidenceRecap.length} 条经历,尚未缩小候选范围。`}
</p>
<p></p>
{turn.evidenceRecap.length > 0 && (
<ul aria-label="已记录的真实经历">
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<span>{entry.dateLabel} · {entry.summary}{entry.isCorrection ? "(已修订)" : ""}</span>
{canAnswer && (
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
composer.current?.focus();
}}
>
</button>
)}
</li>
))}
</ul>
)}
</details>
</div>
</div>
</article>
{controller.pending && canAnswer && (
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
)}
<div
aria-live="polite"
className="conversational-status"
>
{turn.status === "paused" && <p></p>}
{turn.status === "abandoned" && <p></p>}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& <p></p>}
{turn.status === "completed" && turn.candidate.status !== "confirmed"
&& <p></p>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
{turn.status === "paused" && <ChatMessageRow message={{ role: "assistant", text: "校正已暂停,输入与现有证据都已保留。", renderKey: "rectification-paused", state: "settled" }} />}
{turn.status === "abandoned" && <ChatMessageRow message={{ role: "assistant", text: "本次校正已放弃,候选时间没有应用。", renderKey: "rectification-abandoned", state: "settled" }} />}
{turn.status === "completed" && <ChatMessageRow message={{
role: "assistant",
text: turn.candidate.status === "confirmed"
? "候选时间已经过你的明确确认。"
: "候选范围已保存,代表时间没有自动设为当前排盘时间。",
renderKey: "rectification-completed",
state: "settled",
}} />}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
</div>
{turn.actions.includes("continue_original_question")
&& pendingQuestion
&& onContinueOriginalQuestion && (
<section className="conversational-original-question">
<p>{pendingQuestion}</p>
<button
className="button-primary"
disabled={controller.pending || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion)}
>
{continuationPending
? "正在继续回答原问题…"
: turn.candidate.status === "confirmed"
? "使用新确认时间继续回答原问题"
: "返回原对话并继续回答"}
</button>
</section>
)}
{(canAnswer || canConfirm || canContinue) && <div className="composer-wrap rectification-composer-wrap">
{(canConfirm || canContinue) && (
<div className="composer-suggestions" aria-label="生时校正操作">
{canConfirm && (
<button
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
disabled={controller.pending}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{turn.candidate.representativeTime}
</button>
)}
{canContinue && (
<button
disabled={controller.pending || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
>
{continuationPending ? "正在继续回答…" : "返回原问题"}
</button>
)}
</div>
)}
{canAnswer && <>
{controller.correctionTarget && (
<div className="rectification-correction-target" role="status">
<p>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
<button disabled={controller.pending} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
</button>
</div>
)}
<form className="composer" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<label className="sr-only" htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
</label>
<Textarea
id="conversational-rectification-answer"
ref={composer}
autoFocus
disabled={controller.pending}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:更正为 2020 年 11 月离职"
: "像聊天一样回答即可,例如:2018 年 6 月去了上海工作"}
rows={2}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<Button aria-label={controller.pending ? "正在核对" : "发送"} disabled={controller.pending || !controller.draft.trim()} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
</form>
<div className="composer-footer">
<p>{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
</div>
</>}
</div>}
</section>
);
}
@@ -416,9 +281,7 @@ type ConversationalBirthTimeRectificationProps = Readonly<{
onContinueOriginalQuestion?: (question: string) => void;
}>;
export function ConversationalBirthTimeRectification(
props: ConversationalBirthTimeRectificationProps,
) {
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const pendingChange = useRef(props.onPendingChange);
useEffect(() => {
pendingChange.current = props.onPendingChange;
@@ -9,6 +9,7 @@ const evidenceDomainSchema = z.enum([
"career",
"education",
"finance",
"health_pressure",
"relocation",
"relationship",
"family",
@@ -74,7 +75,7 @@ const technicalReceiptSchema = boundedJson(z.object({
}).strict(), 8_192);
const evidenceRequestSchema = boundedJson(z.object({
domains: z.array(evidenceDomainSchema).min(2).max(4),
domains: z.array(evidenceDomainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
freeTextAllowed: z.literal(true),
}).strict(), 2_048);
@@ -25,15 +25,15 @@ type ParsedDate = {
readonly precision: "day" | "month" | "year";
};
const chineseDatePattern = /(?:19|20)\d{2}\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g;
const isoDatePattern = /(?:19|20)\d{2}-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g;
const chineseDatePattern = /(?:1\d{3}|20\d{2})\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g;
const isoDatePattern = /(?:1\d{3}|20\d{2})-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g;
const unresolvedRelativeTimePattern = /(?:次年|第二年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/;
const leadingRelativeTimePattern = /^\s*(?:(?:次年|第二年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/;
const missingEventSummary = "事件内容待补充";
function normalizedDate(value: string): ParsedDate | null {
const chinese = value.match(/^((?:19|20)\d{2})\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
const iso = value.match(/^((?:19|20)\d{2})-(\d{2})(?:-(\d{2}))?$/);
const chinese = value.match(/^((?:1\d{3}|20\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
const iso = value.match(/^((?:1\d{3}|20\d{2}))-(\d{2})(?:-(\d{2}))?$/);
const match = chinese ?? iso;
if (!match) return null;
const year = Number(match[1]);
@@ -81,7 +81,8 @@ function eventSummary(fragment: string): string {
}
function classifyDomain(summary: string): RectificationEvidenceDomain {
if (/毕业|入学|升学|转学|学校|专业|考试|留学|学业/.test(summary)) return "education";
if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|去世|离世|死亡|丧亲|健康/.test(summary)) return "health_pressure";
if (/毕业|入学|升学|转学|学校|大学|专业|考试|留学|学业|学习/.test(summary)) return "education";
if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation";
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship";
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family";
@@ -49,6 +49,7 @@ const domainLabels = {
career: "事业",
education: "学业",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -56,7 +57,7 @@ const domainLabels = {
} as const;
function importedDomain(domain: LifeEvent["domain"]): LifeEventEvidence["domain"] {
return domain === "health_pressure" ? "other" : domain;
return domain;
}
function eventIsWithinHistoricalWindow(
@@ -10,27 +10,16 @@ export type RectificationNarrativePhase = "first" | "intermediate" | "final";
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const modelIdSchema = z.string().trim().min(1).max(120);
const validatorVersion = "rectification-narrative-grounding-v2";
const domainSchema = z.enum(["career", "education", "finance", "relocation", "relationship", "family", "other"]);
const stableSemanticsPattern = /(?:稳定|保持|不变|一致|stable|unchanged)/i;
const sensitiveSemanticsPattern = /(?:敏感|变化|差异|切换|不同|sensitive|changes?|differs?)/i;
const discriminationSemanticsPattern = /(?:区分|辨别|判别|验证|差异|变化|discriminat|distinguish)/i;
const domainSchema = z.enum(["career", "education", "finance", "health_pressure", "relocation", "relationship", "family", "other"]);
const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|\.\.)\s*(?:19|20)\d{2}\s*年?/i;
const explicitYearPattern = /(?:19|20)\d{2}\s*年?/g;
const proposedYearAlternativesPattern = /(?:19|20)\d{2}\s*年?\s*(?:还是|或者|或是|或|、|,|)\s*(?:19|20)\d{2}\s*年?/i;
const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|区间|时期)|哪个时间段|还是|选择|选项|更符合|更匹配|A\s*[.、:)]|B\s*[.、:)]|which\s+(?:year|period|range)|options?)/i;
const domainSemantics = {
career: /(?:事业|工作|职业|career)/i,
education: /(?:教育|学业|学校|education)/i,
finance: /(?:财富|财务|收入|投资|finance)/i,
relocation: /(?:搬迁|搬家|迁居|异地|居住|relocation)/i,
relationship: /(?:关系|婚恋|伴侣|relationship)/i,
family: /(?:家庭|家人|父母|孩子|family)/i,
other: /(?:其他|其它|other)/i,
} as const satisfies Readonly<Record<RectificationEvidenceDomain, RegExp>>;
const domainLabels = {
career: "事业",
education: "学业",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -52,7 +41,7 @@ const narrativeOutputSchema = z.object({
reason: z.string().trim().min(8).max(1_000),
}).strict()).max(6),
evidenceRequest: z.object({
domains: z.array(domainSchema).min(2).max(4),
domains: z.array(domainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
prompt: z.string().trim().min(1).max(1_000),
}).strict().nullable(),
@@ -90,12 +79,6 @@ function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}
function sameMembers(actual: readonly string[], expected: readonly string[]): boolean {
const left = [...new Set(actual)].sort();
const right = [...new Set(expected)].sort();
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function parseModelOutput(text: string): RectificationNarrativeModelOutput {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
@@ -148,27 +131,6 @@ function pairKey(value: { readonly domain: RectificationEvidenceDomain; readonly
return `${value.domain}\0${value.layer}`;
}
function narrativeHasDomainDiscrimination(
narrative: string,
domain: RectificationEvidenceDomain,
layer: string,
): boolean {
return narrative.split(/[。!?!?;\n]/).some((clause) => domainSemantics[domain].test(clause)
&& clause.includes(layer)
&& discriminationSemanticsPattern.test(clause));
}
function narrativeHasLayerEvidence(
narrative: string,
item: RectificationTechnicalPacket["stableLayers"][number],
semantics: RegExp,
requiredValueCount: number,
): boolean {
return narrative.split(/[。!?!?;\n]/).some((clause) => clause.includes(item.layer)
&& item.values.filter((value) => clause.includes(value)).length >= requiredValueCount
&& semantics.test(clause));
}
export function validateNarrativeAgainstPacket(
output: RectificationNarrativeModelOutput,
packet: RectificationTechnicalPacket,
@@ -195,12 +157,6 @@ export function validateNarrativeAgainstPacket(
for (const layer of output.sensitiveLayers) {
if (!allowedSensitive.includes(layer)) issues.push(`sensitive layer ${layer} is not packet-grounded`);
}
if (phase === "first" && !sameMembers(output.stableLayers, allowedStable)) {
issues.push("first turn must carry every stable layer");
}
if (phase === "first" && !sameMembers(output.sensitiveLayers, allowedSensitive)) {
issues.push("first turn must carry every sensitive layer");
}
for (const reference of output.referenceIds) {
if (!packet.referenceIds.includes(reference)) issues.push(`reference ${reference} is not packet-grounded`);
@@ -215,23 +171,10 @@ export function validateNarrativeAgainstPacket(
issues.push(`domainReasons[${index}].reason must use the packet discrimination explanation`);
}
}
if (phase === "first") {
const expectedPairs = packet.suggestedDomains.map(pairKey);
const actualPairs = output.domainReasons.map(pairKey);
if (expectedPairs.length < 2 || !sameMembers(actualPairs, expectedPairs)) {
issues.push("first turn must carry at least two unique packet discrimination pairs");
}
}
if (output.evidenceRequest) {
for (const domain of output.evidenceRequest.domains) {
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
}
if (phase === "first" && !sameMembers(
output.evidenceRequest.domains,
packet.suggestedDomains.map((item) => item.domain),
)) {
issues.push("first evidence request must match the packet discrimination domains");
}
if (!/(?:已经发生|已发生|过去)/.test(output.evidenceRequest.prompt)
|| !/年/.test(output.evidenceRequest.prompt)
|| !/月/.test(output.evidenceRequest.prompt)) {
@@ -260,37 +203,21 @@ export function validateNarrativeAgainstPacket(
issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`);
}
}
if (phase === "first") {
if (!output.narrative.includes(candidate.representativeTime)
|| !/(?:待验证|候选)/.test(output.narrative)) {
issues.push("first narrative must state the pending candidate time");
if (phase !== "final") {
if (!output.narrative.includes(candidate.range.startTime)
|| !output.narrative.includes(candidate.range.endTime)
|| !/(?:待验证|候选|核对)/.test(output.narrative)) {
issues.push("first narrative must state the pending candidate range");
}
for (const item of packet.stableLayers) {
if (!narrativeHasLayerEvidence(output.narrative, item, stableSemanticsPattern, 1)) {
issues.push(`first narrative lacks stable evidence semantics for ${item.layer}`);
}
}
for (const item of packet.sensitiveLayers) {
if (!narrativeHasLayerEvidence(
output.narrative,
item,
sensitiveSemanticsPattern,
Math.min(2, item.values.length),
)) {
issues.push(`first narrative lacks sensitive evidence semantics for ${item.layer}`);
}
}
for (const item of packet.suggestedDomains) {
if (!narrativeHasDomainDiscrimination(output.narrative, item.domain, item.layer)) {
issues.push(`first narrative must explain how ${item.domain}/${item.layer} discriminates`);
}
if (narrativeLayers(output.narrative).length > 0) {
issues.push("visible evidence narrative must not expose technical layer tokens");
}
if (!/(?:已经发生|已发生|过去)/.test(output.narrative)
|| !/年/.test(output.narrative)
|| !/月/.test(output.narrative)) {
issues.push("first narrative must request real past events by year and month");
}
if (!/(?:不是[\s\S]*确认|不能[\s\S]*确定|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) {
if (!/(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) {
issues.push("first narrative must state the candidate use boundary");
}
}
@@ -333,7 +260,8 @@ function promptFor(
candidateFactsMustMatch: true,
onlyListedLayersAndReferences: true,
everyAuthoredStringMustBeGrounded: true,
includeStableAndSensitiveLayerValues: phase === "first",
keepTechnicalLayerValuesOutOfVisibleNarrative: phase !== "final",
askExactlyOneHighInformationQuestion: phase !== "final",
usePacketDomainReasonTextExactly: true,
requestRealPastEventsByYearAndMonth: phase !== "final",
futureWindowsAreContextOnly: true,
@@ -345,24 +273,14 @@ function promptFor(
function fallbackNarrative(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase): string {
const candidate = packet.candidate;
const stable = packet.stableLayers
.map((item) => `${item.layer}${item.values.join(" / ")})保持稳定`)
.join("");
const sensitive = packet.sensitiveLayers
.map((item) => `${item.layer}${item.values.join(" / ")})呈现分钟敏感差异`)
.join("");
const reasons = packet.suggestedDomains
.map((item) => `${domainLabels[item.domain]}事件可区分 ${item.layer}`)
.join("");
const nextDomain = packet.suggestedDomains[0]?.domain;
const nextLabel = nextDomain ? domainLabels[nextDomain] : "重要经历";
const phaseLine = phase === "final"
? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。"
: `下一步请提供上述领域已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么${reasons}`;
: `先说一件已经发生的${nextLabel}事件好吗?尽量写明哪一年、哪一月以及发生了什么。`;
return [
`${candidate.representativeTime} ${candidate.range.startTime}${candidate.range.endTime} 范围内的待验证候选`,
packet.useBoundary,
`${stable || "D1 稳定性暂不可用"}${sensitive} 是当前支持的分钟敏感层。`,
`当前仍在核对 ${candidate.range.startTime}${candidate.range.endTime} 的候选范围,还不能把其中某一分钟当作确定出生时间`,
phaseLine,
"未来窗口只能作为背景,不能计入既成事件评分。",
].join("\n");
}
@@ -382,7 +300,7 @@ function fallbackOutput(
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
evidenceRequest: phase === "final" ? null : {
domains: packet.suggestedDomains.slice(0, 4).map((item) => item.domain),
domains: packet.suggestedDomains.slice(0, 1).map((item) => item.domain),
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并尽量写明哪一年、哪一月以及发生了什么。",
},
@@ -255,6 +255,7 @@ const progressDomainLabels = {
career: "事业",
education: "学业",
finance: "财务",
health_pressure: "健康与重大压力",
relocation: "搬迁",
relationship: "重要关系",
family: "家庭",
@@ -122,6 +122,7 @@ const evidenceDomainSchema = z.enum([
"career",
"education",
"finance",
"health_pressure",
"relocation",
"relationship",
"family",
@@ -6,6 +6,7 @@ export type RectificationEvidenceDomain =
| "career"
| "education"
| "finance"
| "health_pressure"
| "relocation"
| "relationship"
| "family"
@@ -120,6 +121,7 @@ const domainByLayer = {
D11: "finance",
D24: "education",
D4: "relocation",
D30: "health_pressure",
A7: "relationship",
UL: "relationship",
A10: "career",
@@ -129,6 +131,7 @@ const domainLabels = {
career: "事业",
education: "教育",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -211,7 +214,7 @@ function candidateWeights(model: Readonly<Record<string, unknown>>): Readonly<Re
}
function eventDomain(domain: CandidateResult["evidence"][number]["domain"]): RectificationEvidenceDomain {
return domain === "health_pressure" ? "other" : domain;
return domain;
}
function layerEvidence(
@@ -0,0 +1,49 @@
begin;
-- The language-first evidence extractor and question planner use
-- health_pressure as a first-class rectification domain. The durable SQL
-- contract still rejected that domain in a public evidence request, so the
-- first scored answer could finish all calculation/model work and then fail
-- at save_conversational_rectification_turn with action_conflict.
do $migration$
declare
v_signature text;
v_definition text;
v_updated_definition text;
v_old_domains constant text := '''finance'', ''relocation''';
v_new_domains constant text := '''finance'', ''health_pressure'', ''relocation''';
begin
foreach v_signature in array array[
'public.conversational_rectification_valid_evidence_request(jsonb)',
'public.conversational_rectification_valid_evidence_recap(jsonb)',
'public.conversational_rectification_valid_life_event_evidence(jsonb)',
'public.conversational_rectification_valid_private_candidate(jsonb)'
] loop
select pg_catalog.pg_get_functiondef(v_signature::regprocedure)
into v_definition;
v_updated_definition := pg_catalog.replace(
v_definition,
v_old_domains,
v_new_domains
);
if v_updated_definition is not distinct from v_definition then
raise exception 'health pressure domain migration could not update %', v_signature;
end if;
execute v_updated_definition;
end loop;
end;
$migration$;
alter table public.birth_time_rectification_event_evidence
drop constraint if exists birth_time_rectification_event_evidence_domain_check;
alter table public.birth_time_rectification_event_evidence
add constraint birth_time_rectification_event_evidence_domain_check
check (
domain in (
'career', 'education', 'finance', 'health_pressure', 'relocation',
'relationship', 'family', 'other'
)
);
commit;
@@ -0,0 +1,28 @@
begin;
-- Language-first rectification asks one focused domain at a time. The
-- application contract already permits that, while durable SQL still required
-- at least two domains and rejected otherwise-valid turns as action_conflict.
do $migration$
declare
v_signature constant text :=
'public.conversational_rectification_valid_evidence_request(jsonb)';
v_definition text;
v_updated_definition text;
begin
select pg_catalog.pg_get_functiondef(v_signature::regprocedure)
into v_definition;
v_updated_definition := pg_catalog.replace(
v_definition,
'between 2 and 4',
'between 1 and 4'
);
if v_updated_definition is not distinct from v_definition then
raise exception 'evidence request cardinality migration could not update %',
v_signature;
end if;
execute v_updated_definition;
end;
$migration$;
commit;
@@ -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 -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", () => {
@@ -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)
@@ -889,11 +899,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"));
@@ -917,12 +927,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 () => {
+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/);