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(