feat: make birth-time rectification conversational
This commit is contained in:
@@ -21,7 +21,10 @@ import {
|
||||
type DeclaredBirthInput,
|
||||
type LifeEventEvidence,
|
||||
} from "../../../lib/conversational-rectification/persistence-contracts.ts";
|
||||
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
|
||||
import {
|
||||
rectificationNarrativeOutputSchema,
|
||||
type RectificationNarrativeGenerator,
|
||||
} from "../../../lib/conversational-rectification/narrative-agent.ts";
|
||||
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
|
||||
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
|
||||
import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts";
|
||||
@@ -353,6 +356,7 @@ function scoreableLifeEvents(
|
||||
domain: item.domain,
|
||||
precision: item.datePrecision as "day" | "month" | "year",
|
||||
date: item.dateValue,
|
||||
summary: item.eventSummary,
|
||||
} as LifeEvent];
|
||||
}).slice(-MAXIMUM_SCOREABLE_EVENTS);
|
||||
}
|
||||
@@ -671,13 +675,21 @@ async function productionNarrativeGenerator(): Promise<RectificationNarrativeGen
|
||||
name: "Conversational Rectification Narrator",
|
||||
model: model.model,
|
||||
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.",
|
||||
instructions: "Return only the exact JSON object requested by the user prompt. Load the Jyotish Skill to choose a natural, one-question-at-a-time evidence strategy and to explain the supplied expert workflow. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, technique states, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data. A blocked or not_evaluated technique must never be described as used. On final turns, include a concise user-readable Technique Audit Table from packet.expertWorkflow; on collecting turns, keep the reply conversational and ask exactly one next question.",
|
||||
});
|
||||
return {
|
||||
modelId: model.id,
|
||||
async generate(prompt) {
|
||||
const result = await agent.generate([{ role: "user", content: prompt }]);
|
||||
return { text: result.text };
|
||||
const result = await agent.generate(
|
||||
[{ role: "user", content: prompt }],
|
||||
{
|
||||
structuredOutput: {
|
||||
schema: rectificationNarrativeOutputSchema,
|
||||
jsonPromptInjection: "inline",
|
||||
},
|
||||
},
|
||||
);
|
||||
return { text: JSON.stringify(rectificationNarrativeOutputSchema.parse(result.object)) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+16
-25
@@ -830,8 +830,6 @@ export default function Home() {
|
||||
const activeOnboardingRequestIdentity = useRef("");
|
||||
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
|
||||
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator<Theme>());
|
||||
const continueRectificationQuestion = useRef<(question: string) => void>(() => undefined);
|
||||
const automaticRectificationContinuation = useRef("");
|
||||
const durableRectificationQuestionHandoff = useRef(
|
||||
createDurableRectificationQuestionHandoffClient(),
|
||||
);
|
||||
@@ -2632,6 +2630,19 @@ export default function Home() {
|
||||
&& !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) {
|
||||
rectificationQuestionHandoff.current.clear();
|
||||
}
|
||||
const localHandoff = rectificationQuestionHandoff.current.peek();
|
||||
const returnSession = (localHandoff
|
||||
? sessions.find((session) => session.id === localHandoff.sessionId)
|
||||
: null)
|
||||
?? (rectificationReturnSessionId
|
||||
? sessions.find((session) => session.id === rectificationReturnSessionId)
|
||||
: null)
|
||||
?? sessions.find((session) => session.sessionType === "consultation")
|
||||
?? null;
|
||||
if (!returnSession) {
|
||||
setComposerNotice("没有找到原问题所在的会话,请从会话列表打开原问题后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
rectificationContinuationInFlight.current = true;
|
||||
setRectificationContinuationPending(true);
|
||||
@@ -2647,12 +2658,8 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
if (durableClaim.status === "consumed") {
|
||||
const returnSessionId = rectificationQuestionHandoff.current.peek()?.sessionId
|
||||
?? rectificationReturnSessionId;
|
||||
if (returnSessionId && sessions.some((session) => session.id === returnSessionId)) {
|
||||
activeSessionIdRef.current = returnSessionId;
|
||||
setActiveSessionId(returnSessionId);
|
||||
}
|
||||
activeSessionIdRef.current = returnSession.id;
|
||||
setActiveSessionId(returnSession.id);
|
||||
setRectificationPendingQuestion(null);
|
||||
setComposerNotice("原问题已经继续回答,不会再次发送或扣点。");
|
||||
return;
|
||||
@@ -2663,7 +2670,7 @@ export default function Home() {
|
||||
}
|
||||
const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion(
|
||||
question,
|
||||
{ sessionId: activeSession.id, theme: activeSession.theme },
|
||||
{ sessionId: returnSession.id, theme: returnSession.theme },
|
||||
async (context) => {
|
||||
activeSessionIdRef.current = context.sessionId;
|
||||
setActiveSessionId(context.sessionId);
|
||||
@@ -2702,22 +2709,6 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
continueRectificationQuestion.current = (question) => {
|
||||
void continueRectificationOriginalQuestion(question);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const turn = rectificationInitialTurn;
|
||||
const question = turn?.pendingConsultationQuestion;
|
||||
if (!turn || turn.status !== "completed" || !question
|
||||
|| !turn.actions.includes("continue_original_question")
|
||||
|| rectificationLoading || rectificationMutationPending) return;
|
||||
const continuationIdentity = `${turn.caseId}:${turn.turnVersion}:${question}`;
|
||||
if (automaticRectificationContinuation.current === continuationIdentity) return;
|
||||
automaticRectificationContinuation.current = continuationIdentity;
|
||||
continueRectificationQuestion.current(question);
|
||||
}, [rectificationInitialTurn, rectificationLoading, rectificationMutationPending]);
|
||||
|
||||
useGSAP(() => {
|
||||
if (!starterHomeVisible || !starterWorkbench.current) return;
|
||||
const motion = gsap.matchMedia();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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 { ArrowUp, Square } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChatMessageRow } from "./chat-message-row.tsx";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Textarea } from "./ui/textarea.tsx";
|
||||
import {
|
||||
@@ -12,62 +11,6 @@ import {
|
||||
} from "../hooks/use-conversational-rectification.ts";
|
||||
import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts";
|
||||
|
||||
type EvidenceDomain = NonNullable<
|
||||
ConversationalRectificationTurn["evidenceRequest"]
|
||||
>["domains"][number];
|
||||
|
||||
const domainLabels = {
|
||||
career: "事业与身份",
|
||||
education: "学业与学习",
|
||||
finance: "收入与资产",
|
||||
health_pressure: "健康与重大压力",
|
||||
relocation: "搬迁与居住地",
|
||||
relationship: "重要关系",
|
||||
family: "家庭变化",
|
||||
other: "其他关键经历",
|
||||
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
|
||||
|
||||
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)))
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function visibleTurnNarrative(turn: ConversationalRectificationTurn): string {
|
||||
if (["paused", "abandoned", "completed"].includes(turn.status)) {
|
||||
return turn.narrative;
|
||||
}
|
||||
|
||||
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}。范围暂未变化不代表提交失败,我会结合后续经历继续比较相邻分钟。`,
|
||||
nextQuestion,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
type SurfaceProps = Readonly<{
|
||||
controller: ConversationalRectificationController;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
@@ -75,6 +18,8 @@ type SurfaceProps = Readonly<{
|
||||
onContinueOriginalQuestion?: (question: string) => void;
|
||||
}>;
|
||||
|
||||
const ANSWER_UNDO_WINDOW_MS = 2_500;
|
||||
|
||||
function safely(request: Promise<unknown>) {
|
||||
void request.catch(() => undefined);
|
||||
}
|
||||
@@ -93,7 +38,16 @@ export function ConversationalRectificationSurface({
|
||||
onContinueOriginalQuestion,
|
||||
}: SurfaceProps) {
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [submission, setSubmission] = useState<Readonly<{
|
||||
text: string;
|
||||
phase: "undo" | "generating";
|
||||
turnVersion: number;
|
||||
}> | null>(null);
|
||||
const turn = controller.turn;
|
||||
useEffect(() => () => {
|
||||
if (undoTimer.current) clearTimeout(undoTimer.current);
|
||||
}, []);
|
||||
const pendingQuestion = turn?.status === "completed"
|
||||
? turn.pendingConsultationQuestion
|
||||
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
@@ -114,42 +68,59 @@ export function ConversationalRectificationSurface({
|
||||
const canContinue = turn.actions.includes("continue_original_question")
|
||||
&& Boolean(pendingQuestion)
|
||||
&& Boolean(onContinueOriginalQuestion);
|
||||
const submit = async () => {
|
||||
if (!canAnswer || !controller.draft.trim() || controller.pending) return;
|
||||
try {
|
||||
await controller.answer(undefined, controller.draft.trim());
|
||||
composer.current?.focus();
|
||||
} catch {
|
||||
// The controller keeps the draft and owns the visible error.
|
||||
}
|
||||
const busy = controller.pending || submission !== null;
|
||||
const submit = () => {
|
||||
const text = controller.draft.trim();
|
||||
if (!canAnswer || !text || busy) return;
|
||||
controller.setDraft("");
|
||||
setSubmission({ text, phase: "undo", turnVersion: turn.turnVersion });
|
||||
undoTimer.current = setTimeout(async () => {
|
||||
undoTimer.current = null;
|
||||
setSubmission({ text, phase: "generating", turnVersion: turn.turnVersion });
|
||||
try {
|
||||
await controller.answer(undefined, text);
|
||||
} catch {
|
||||
controller.setDraft(text);
|
||||
} finally {
|
||||
setSubmission(null);
|
||||
composer.current?.focus();
|
||||
}
|
||||
}, ANSWER_UNDO_WINDOW_MS);
|
||||
};
|
||||
const undoSubmission = () => {
|
||||
if (submission?.phase !== "undo") return;
|
||||
if (undoTimer.current) clearTimeout(undoTimer.current);
|
||||
undoTimer.current = null;
|
||||
controller.setDraft(submission.text);
|
||||
setSubmission(null);
|
||||
requestAnimationFrame(() => composer.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rectification-chat" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
|
||||
<div className="message-list rectification-message-list">
|
||||
<span className="sr-only" aria-live="polite">{controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
|
||||
{turn.evidenceRecap.map((entry) => (
|
||||
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
|
||||
{(controller.messages ?? [{
|
||||
role: "assistant" as const,
|
||||
text: turn.narrative,
|
||||
renderKey: `assistant-${turn.turnVersion}`,
|
||||
}]).map((message) => (
|
||||
<ChatMessageRow
|
||||
key={entry.id}
|
||||
key={message.renderKey}
|
||||
message={{
|
||||
role: "user",
|
||||
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
|
||||
renderKey: entry.id,
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
renderKey: message.renderKey,
|
||||
state: "settled",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{controller.pending && controller.draft.trim() && (
|
||||
{submission && turn.turnVersion === submission.turnVersion && (
|
||||
<ChatMessageRow
|
||||
message={{ role: "user", text: controller.draft.trim(), renderKey: "pending-evidence", state: "settled" }}
|
||||
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
<details className="rectification-message-details rectification-progress-details">
|
||||
<summary>
|
||||
{turn.candidate.representativeTime
|
||||
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
|
||||
@@ -169,7 +140,7 @@ export function ConversationalRectificationSurface({
|
||||
{canAnswer && (
|
||||
<button
|
||||
aria-label={`更正这条经历:${entry.summary}`}
|
||||
disabled={controller.pending}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
controller.beginEvidenceCorrection(entry.id);
|
||||
@@ -183,10 +154,7 @@ export function ConversationalRectificationSurface({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</details>
|
||||
{controller.pending && canAnswer && (
|
||||
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
|
||||
)}
|
||||
@@ -209,7 +177,7 @@ export function ConversationalRectificationSurface({
|
||||
{canConfirm && (
|
||||
<button
|
||||
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
|
||||
disabled={controller.pending}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
|
||||
>
|
||||
@@ -218,7 +186,7 @@ export function ConversationalRectificationSurface({
|
||||
)}
|
||||
{canContinue && (
|
||||
<button
|
||||
disabled={controller.pending || continuationPending}
|
||||
disabled={busy || continuationPending}
|
||||
type="button"
|
||||
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
|
||||
>
|
||||
@@ -231,12 +199,12 @@ export function ConversationalRectificationSurface({
|
||||
{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 disabled={busy} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
|
||||
取消更正
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="composer" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<form className="composer" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<label className="sr-only" htmlFor="conversational-rectification-answer">
|
||||
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
|
||||
</label>
|
||||
@@ -244,7 +212,7 @@ export function ConversationalRectificationSurface({
|
||||
id="conversational-rectification-answer"
|
||||
ref={composer}
|
||||
autoFocus
|
||||
disabled={controller.pending}
|
||||
disabled={busy}
|
||||
maxLength={4_000}
|
||||
placeholder={controller.correctionTarget
|
||||
? "例如:更正为 2020 年 11 月离职"
|
||||
@@ -259,12 +227,20 @@ export function ConversationalRectificationSurface({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button aria-label={controller.pending ? "正在核对" : "发送"} disabled={controller.pending || !controller.draft.trim()} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
{submission?.phase === "undo" ? (
|
||||
<Button aria-label="撤回发送,本次不计入校正" title="撤回发送" size="icon" type="button" onClick={undoSubmission}>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={busy ? "正在核对" : "发送"} disabled={busy || !controller.draft.trim()} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<div className="composer-footer">
|
||||
<p>{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
|
||||
<p>{submission?.phase === "undo"
|
||||
? "已发送,2.5 秒内可撤回修改,本次不会计入校正。"
|
||||
: busy ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
@@ -18,8 +18,41 @@ type EvidenceDomain = NonNullable<
|
||||
>["domains"][number];
|
||||
type EvidenceRecapEntry = ConversationalRectificationTurn["evidenceRecap"][number];
|
||||
|
||||
export type ConversationalRectificationMessage = Readonly<{
|
||||
role: "assistant" | "user";
|
||||
text: string;
|
||||
renderKey: string;
|
||||
}>;
|
||||
|
||||
function assistantText(turn: ConversationalRectificationTurn): string {
|
||||
return turn.narrative.trim();
|
||||
}
|
||||
|
||||
function initialMessages(turn: ConversationalRectificationTurn | null): ConversationalRectificationMessage[] {
|
||||
if (!turn) return [];
|
||||
if (turn.evidenceRecap.length === 0) {
|
||||
return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }];
|
||||
}
|
||||
return turn.evidenceRecap.flatMap((entry, index) => {
|
||||
const user = {
|
||||
role: "user" as const,
|
||||
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
|
||||
renderKey: `user-${entry.id}`,
|
||||
};
|
||||
const assistant = index === turn.evidenceRecap.length - 1
|
||||
? assistantText(turn)
|
||||
: `已记录这段经历:${entry.dateLabel} · ${entry.summary}。`;
|
||||
return [user, {
|
||||
role: "assistant" as const,
|
||||
text: assistant,
|
||||
renderKey: `assistant-history-${entry.id}`,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export type ConversationalRectificationControllerSnapshot = Readonly<{
|
||||
turn: ConversationalRectificationTurn | null;
|
||||
messages?: readonly ConversationalRectificationMessage[];
|
||||
draft: string;
|
||||
selectedDomain: EvidenceDomain | null;
|
||||
correctionTarget: EvidenceRecapEntry | null;
|
||||
@@ -57,6 +90,7 @@ type Mutation = Readonly<{
|
||||
identity: ConversationalRectificationActionIdentity;
|
||||
command(actionId: string): ConversationalRectificationCommand;
|
||||
clearDraftOnSuccess?: boolean;
|
||||
userMessage?: string;
|
||||
}>;
|
||||
|
||||
type ActiveMutation = Readonly<{
|
||||
@@ -103,6 +137,7 @@ export function createConversationalRectificationController(
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: ConversationalRectificationControllerSnapshot = {
|
||||
turn: input.initialTurn ?? null,
|
||||
messages: initialMessages(input.initialTurn ?? null),
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
@@ -132,6 +167,7 @@ export function createConversationalRectificationController(
|
||||
turn: ConversationalRectificationTurn,
|
||||
clearDraft: boolean,
|
||||
expectedCaseContext: number,
|
||||
userMessage?: string,
|
||||
) => {
|
||||
const current = snapshot.turn;
|
||||
if (caseContext !== expectedCaseContext) return turn;
|
||||
@@ -148,6 +184,13 @@ export function createConversationalRectificationController(
|
||||
: null;
|
||||
patch({
|
||||
turn,
|
||||
messages: userMessage
|
||||
? [
|
||||
...(snapshot.messages ?? []),
|
||||
{ role: "user", text: userMessage, renderKey: `user-${turn.turnVersion}` },
|
||||
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
|
||||
]
|
||||
: snapshot.messages,
|
||||
error: "",
|
||||
selectedDomain,
|
||||
correctionTarget,
|
||||
@@ -187,6 +230,7 @@ export function createConversationalRectificationController(
|
||||
turn,
|
||||
mutation.clearDraftOnSuccess === true,
|
||||
caseContextAtStart,
|
||||
mutation.userMessage,
|
||||
))
|
||||
.catch(async (error: unknown) => {
|
||||
if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) {
|
||||
@@ -222,6 +266,7 @@ export function createConversationalRectificationController(
|
||||
payload: unknown,
|
||||
command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand,
|
||||
clearDraftOnSuccess = false,
|
||||
userMessage?: string,
|
||||
): MutationResult => {
|
||||
const turn = currentTurn();
|
||||
if (!turn) return Promise.resolve(null);
|
||||
@@ -234,11 +279,13 @@ export function createConversationalRectificationController(
|
||||
},
|
||||
command: (actionId) => command(turn, actionId),
|
||||
clearDraftOnSuccess,
|
||||
userMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const controller = {
|
||||
get turn() { return snapshot.turn; },
|
||||
get messages() { return snapshot.messages; },
|
||||
get draft() { return snapshot.draft; },
|
||||
get selectedDomain() { return snapshot.selectedDomain; },
|
||||
get correctionTarget() { return snapshot.correctionTarget; },
|
||||
@@ -262,6 +309,7 @@ export function createConversationalRectificationController(
|
||||
}
|
||||
patch({
|
||||
turn: null,
|
||||
messages: [],
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
@@ -280,6 +328,7 @@ export function createConversationalRectificationController(
|
||||
}
|
||||
patch({
|
||||
turn,
|
||||
messages: initialMessages(turn),
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
@@ -291,6 +340,10 @@ export function createConversationalRectificationController(
|
||||
if (turn.turnVersion <= current.turnVersion) return;
|
||||
patch({
|
||||
turn,
|
||||
messages: [
|
||||
...(snapshot.messages ?? []),
|
||||
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
|
||||
],
|
||||
error: "",
|
||||
selectedDomain: snapshot.selectedDomain
|
||||
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
|
||||
@@ -360,7 +413,7 @@ export function createConversationalRectificationController(
|
||||
answer,
|
||||
...(domain ? { domain } : {}),
|
||||
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
|
||||
}), true);
|
||||
}), true, answer);
|
||||
},
|
||||
pause() {
|
||||
const turn = currentTurn();
|
||||
|
||||
@@ -16,6 +16,7 @@ export const lifeEventPrecisionSchema = z.enum(["year", "month", "day"]);
|
||||
const lifeEventBase = {
|
||||
id: z.string().uuid(),
|
||||
domain: lifeEventDomainSchema,
|
||||
summary: z.string().trim().min(1).max(1_000).optional(),
|
||||
} as const;
|
||||
|
||||
const supportedYearSchema = z.string().regex(/^(19|20)\d{2}$/).refine(
|
||||
|
||||
@@ -92,6 +92,7 @@ export function eventScorePayload(input: JourneyEventScoreInput) {
|
||||
domain: event.domain,
|
||||
date: event.date,
|
||||
precision: event.precision,
|
||||
...(event.summary ? { summary: event.summary } : {}),
|
||||
})),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -48,12 +48,15 @@ export function rangeCompletionReason(input: Readonly<{
|
||||
packet: RectificationTechnicalPacket;
|
||||
scoreableEventCount: number;
|
||||
plateauCount: number;
|
||||
unansweredSuggestedDomainCount: number;
|
||||
}>): RangeCompletionReason | null {
|
||||
if (input.packet.candidate.status === "ready_for_confirmation") return null;
|
||||
if (input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS) return null;
|
||||
if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS) return "evidence_limit";
|
||||
if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS
|
||||
&& input.unansweredSuggestedDomainCount === 0) return "evidence_limit";
|
||||
if (input.packet.suggestedDomains.length === 0) return "no_discriminating_question";
|
||||
if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS) return "range_plateau";
|
||||
if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS
|
||||
&& input.unansweredSuggestedDomainCount === 0) return "range_plateau";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ function classifyDomain(summary: string): RectificationEvidenceDomain {
|
||||
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship";
|
||||
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family";
|
||||
if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) return "finance";
|
||||
if (/工作|入职|离职|辞职|升职|创业|职业|公司|项目/.test(summary)) return "career";
|
||||
if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) return "career";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -137,6 +137,36 @@ function splitSentences(value: string): string[][] {
|
||||
return sentences.length > 0 ? sentences : [[value.trim()]];
|
||||
}
|
||||
|
||||
function coalesceSameEventDetails(
|
||||
input: ExtractLifeEventEvidenceInput,
|
||||
events: readonly ExtractedLifeEventEvidence[],
|
||||
): readonly ExtractedLifeEventEvidence[] {
|
||||
const merged: ExtractedLifeEventEvidence[] = [];
|
||||
for (const event of events) {
|
||||
const previous = merged.at(-1);
|
||||
const canMerge = previous
|
||||
&& previous.dateValue !== null
|
||||
&& previous.dateValue === event.dateValue
|
||||
&& previous.datePrecision === event.datePrecision
|
||||
&& previous.domain === event.domain
|
||||
&& previous.extractionStatus === event.extractionStatus
|
||||
&& previous.scoreable === event.scoreable
|
||||
&& previous.correctsEvidenceIds.join("\0") === event.correctsEvidenceIds.join("\0");
|
||||
if (!canMerge) {
|
||||
merged.push(event);
|
||||
continue;
|
||||
}
|
||||
const summaries = [...new Set([previous.eventSummary, event.eventSummary])];
|
||||
const eventSummary = summaries.join(";");
|
||||
merged[merged.length - 1] = {
|
||||
...previous,
|
||||
id: evidenceId(input, merged.length - 1, eventSummary),
|
||||
eventSummary,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function extractLifeEventEvidence(
|
||||
input: ExtractLifeEventEvidenceInput,
|
||||
): readonly ExtractedLifeEventEvidence[] {
|
||||
@@ -173,5 +203,5 @@ export function extractLifeEventEvidence(
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
return coalesceSameEventDetails(input, events);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,40 @@ import {
|
||||
|
||||
export type RectificationNarrativePhase = "first" | "intermediate" | "final";
|
||||
|
||||
export type RectificationNarrativeContext = Readonly<{
|
||||
latestUserText?: string;
|
||||
latestEvidence?: ReadonlyArray<{
|
||||
dateLabel: string;
|
||||
summary: string;
|
||||
domain: RectificationEvidenceDomain;
|
||||
}>;
|
||||
eventLedger?: ReadonlyArray<{
|
||||
id: string;
|
||||
rawText: string;
|
||||
dateLabel: string;
|
||||
summary: string;
|
||||
domain: RectificationEvidenceDomain;
|
||||
extractionStatus: "clear" | "needs_clarification" | "corrected";
|
||||
active: boolean;
|
||||
correctsEvidenceIds: readonly string[];
|
||||
}>;
|
||||
unresolvedEvidence?: ReadonlyArray<{
|
||||
id: string;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
domain: RectificationEvidenceDomain;
|
||||
dateLabel: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
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", "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 labeledYearChoicesPattern = /A\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?[\s\S]{0,120}B\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?/i;
|
||||
const domainLabels = {
|
||||
career: "事业",
|
||||
education: "学业",
|
||||
@@ -25,7 +51,7 @@ const domainLabels = {
|
||||
family: "家庭",
|
||||
other: "其他",
|
||||
} as const satisfies Readonly<Record<RectificationEvidenceDomain, string>>;
|
||||
const narrativeOutputSchema = z.object({
|
||||
export const rectificationNarrativeOutputSchema = z.object({
|
||||
narrative: z.string().trim().min(1).max(12_000),
|
||||
candidateStatus: z.enum(["pending_validation", "ready_for_confirmation"]),
|
||||
representativeTime: timeSchema,
|
||||
@@ -47,7 +73,7 @@ const narrativeOutputSchema = z.object({
|
||||
}).strict().nullable(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationNarrativeModelOutput = z.infer<typeof narrativeOutputSchema>;
|
||||
export type RectificationNarrativeModelOutput = z.infer<typeof rectificationNarrativeOutputSchema>;
|
||||
|
||||
export type NarrativeValidation = {
|
||||
readonly valid: boolean;
|
||||
@@ -84,7 +110,7 @@ function parseModelOutput(text: string): RectificationNarrativeModelOutput {
|
||||
const start = normalized.indexOf("{");
|
||||
const end = normalized.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new TypeError("narrative output is not JSON");
|
||||
return narrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1)));
|
||||
return rectificationNarrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1)));
|
||||
}
|
||||
|
||||
function narrativeTimes(value: string): string[] {
|
||||
@@ -104,11 +130,9 @@ function narrativeReferences(value: string): string[] {
|
||||
}
|
||||
|
||||
function isGenericBroadYearChoiceQuestionnaire(value: string): boolean {
|
||||
const distinctYears = unique((value.match(explicitYearPattern) ?? [])
|
||||
.map((year) => year.replace(/\s*年$/, "")));
|
||||
return proposedYearAlternativesPattern.test(value)
|
||||
|| (choiceQuestionPattern.test(value)
|
||||
&& (broadYearRangePattern.test(value) || distinctYears.length >= 2));
|
||||
|| labeledYearChoicesPattern.test(value)
|
||||
|| (choiceQuestionPattern.test(value) && broadYearRangePattern.test(value));
|
||||
}
|
||||
|
||||
function proseFields(output: RectificationNarrativeModelOutput): readonly {
|
||||
@@ -175,9 +199,7 @@ export function validateNarrativeAgainstPacket(
|
||||
for (const domain of output.evidenceRequest.domains) {
|
||||
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
|
||||
}
|
||||
if (!/(?:已经发生|已发生|过去)/.test(output.evidenceRequest.prompt)
|
||||
|| !/年/.test(output.evidenceRequest.prompt)
|
||||
|| !/月/.test(output.evidenceRequest.prompt)) {
|
||||
if (!requestsPastDatedEvent(output.evidenceRequest.prompt)) {
|
||||
issues.push("evidence request must ask for a real past event by year and month");
|
||||
}
|
||||
} else if (phase !== "final") {
|
||||
@@ -203,7 +225,7 @@ export function validateNarrativeAgainstPacket(
|
||||
issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`);
|
||||
}
|
||||
}
|
||||
if (phase !== "final") {
|
||||
if (phase === "first") {
|
||||
if (!output.narrative.includes(candidate.range.startTime)
|
||||
|| !output.narrative.includes(candidate.range.endTime)
|
||||
|| !/(?:待验证|候选|核对)/.test(output.narrative)) {
|
||||
@@ -212,9 +234,7 @@ export function validateNarrativeAgainstPacket(
|
||||
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)) {
|
||||
if (!requestsPastDatedEvent(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)) {
|
||||
@@ -238,6 +258,7 @@ function grounding(packet: RectificationTechnicalPacket) {
|
||||
suggestedDomains: packet.suggestedDomains,
|
||||
referenceIds: packet.referenceIds,
|
||||
futureWindows: projected.futureWindows,
|
||||
expertWorkflow: packet.expertWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +268,89 @@ function boundedReceiptIssues(issues: readonly string[]): string[] {
|
||||
.map((issue) => issue.trim().slice(0, 240) || "narrative_mismatch");
|
||||
}
|
||||
|
||||
function ensureSentence(value: string, sentence: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? `${trimmed}\n${sentence}` : sentence;
|
||||
}
|
||||
|
||||
function hasVisibleEvidenceQuestion(value: string): boolean {
|
||||
const withoutRhetoricalPrompts = value.replace(/(?:好吗|可以吗|行吗)[??]/g, "");
|
||||
return /[??]/.test(withoutRhetoricalPrompts)
|
||||
|| /请(?:先|再|补充|告诉|提供|确认|回忆)/.test(value)
|
||||
|| /(?:先说一件|说说|告诉我)/.test(value);
|
||||
}
|
||||
|
||||
function requestsPastDatedEvent(value: string): boolean {
|
||||
const asksForDate = /(?:年|月|日期|时间|什么时候)/.test(value);
|
||||
const refersToPastEvent = /(?:已经发生|已发生|过去|当时|后来|经历|发生|开始|毕业|入职|离职|结束|分手|事故|手术)/.test(value);
|
||||
const asksOnlyAboutFuture = /(?:未来|预计|计划|打算)/.test(value) && !refersToPastEvent;
|
||||
return asksForDate && refersToPastEvent && !asksOnlyAboutFuture;
|
||||
}
|
||||
|
||||
function repairRequiredSafetyLanguage(
|
||||
output: RectificationNarrativeModelOutput,
|
||||
packet: RectificationTechnicalPacket,
|
||||
phase: RectificationNarrativePhase,
|
||||
): RectificationNarrativeModelOutput {
|
||||
let narrative = output.narrative;
|
||||
let evidenceRequest = output.evidenceRequest;
|
||||
const modelEvidencePrompt = output.evidenceRequest?.prompt.trim() ?? "";
|
||||
|
||||
if (phase !== "final" && evidenceRequest) {
|
||||
if (!requestsPastDatedEvent(evidenceRequest.prompt)) {
|
||||
evidenceRequest = {
|
||||
...evidenceRequest,
|
||||
prompt: `请以已经发生的真实事件为准,并尽量说明年份和月份。${evidenceRequest.prompt}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "first") {
|
||||
const candidate = packet.candidate;
|
||||
const statesCandidateRange = narrative.includes(candidate.range.startTime)
|
||||
&& narrative.includes(candidate.range.endTime)
|
||||
&& /(?:待验证|候选|核对)/.test(narrative);
|
||||
const statesUseBoundary = /(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(narrative);
|
||||
|
||||
if (!statesCandidateRange || !statesUseBoundary) {
|
||||
narrative = [
|
||||
`我们先在 ${candidate.range.startTime}–${candidate.range.endTime} 内核对候选;这个范围不能直接当作已经确认的出生时间。`,
|
||||
narrative.trim(),
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// evidenceRequest.prompt is an internal planning field and is intentionally not
|
||||
// projected to the public turn. Keep the model-authored acknowledgement, but make
|
||||
// sure the one concrete follow-up question is also visible in the chat bubble.
|
||||
if (phase !== "final" && evidenceRequest && !hasVisibleEvidenceQuestion(narrative)) {
|
||||
narrative = ensureSentence(narrative, modelEvidencePrompt || evidenceRequest.prompt);
|
||||
}
|
||||
|
||||
if (phase === "first" && !requestsPastDatedEvent(narrative)) {
|
||||
narrative = ensureSentence(
|
||||
narrative,
|
||||
"请从已经发生的真实经历开始,尽量写明哪一年、哪一月。",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...output,
|
||||
narrative,
|
||||
evidenceRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function promptFor(
|
||||
phase: RectificationNarrativePhase,
|
||||
packet: RectificationTechnicalPacket,
|
||||
context: RectificationNarrativeContext,
|
||||
retryIssues: readonly string[] = [],
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
task: "write_grounded_rectification_narrative",
|
||||
phase,
|
||||
conversationContext: context,
|
||||
packet: grounding(packet),
|
||||
outputContract: {
|
||||
candidateFactsMustMatch: true,
|
||||
@@ -262,10 +358,21 @@ function promptFor(
|
||||
everyAuthoredStringMustBeGrounded: true,
|
||||
keepTechnicalLayerValuesOutOfVisibleNarrative: phase !== "final",
|
||||
askExactlyOneHighInformationQuestion: phase !== "final",
|
||||
acknowledgeLatestEvidenceSpecificallyBeforeAsking: phase === "intermediate",
|
||||
doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate",
|
||||
finishCurrentEventBeforeSwitchingDomains: phase === "intermediate",
|
||||
resolveDateContradictionsBeforeScoring: phase === "intermediate",
|
||||
mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate",
|
||||
askForTheSingleMostInformativeMissingDetail: phase === "intermediate",
|
||||
treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate",
|
||||
useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate",
|
||||
usePacketDomainReasonTextExactly: true,
|
||||
requestRealPastEventsByYearAndMonth: phase !== "final",
|
||||
futureWindowsAreContextOnly: true,
|
||||
genericBroadYearRangeQuestionnaireForbidden: true,
|
||||
useExpertWorkflowAsTechniqueTruth: true,
|
||||
blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed: true,
|
||||
finalNarrativeIncludesAConciseTechniqueAuditTable: phase === "final",
|
||||
},
|
||||
retryIssues: boundedReceiptIssues(retryIssues),
|
||||
});
|
||||
@@ -311,13 +418,23 @@ export async function generateRectificationNarrative(input: {
|
||||
readonly phase: RectificationNarrativePhase;
|
||||
readonly packet: RectificationTechnicalPacket;
|
||||
readonly generator: RectificationNarrativeGenerator;
|
||||
readonly context?: RectificationNarrativeContext;
|
||||
}): Promise<RectificationNarrativeResult> {
|
||||
const modelId = modelIdSchema.parse(input.generator.modelId);
|
||||
let issues: readonly string[] = [];
|
||||
for (const attempt of [1, 2] as const) {
|
||||
try {
|
||||
const generated = await input.generator.generate(promptFor(input.phase, input.packet, issues));
|
||||
const output = parseModelOutput(generated.text);
|
||||
const generated = await input.generator.generate(promptFor(
|
||||
input.phase,
|
||||
input.packet,
|
||||
input.context ?? {},
|
||||
issues,
|
||||
));
|
||||
const output = repairRequiredSafetyLanguage(
|
||||
parseModelOutput(generated.text),
|
||||
input.packet,
|
||||
input.phase,
|
||||
);
|
||||
const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
@@ -338,10 +455,18 @@ export async function generateRectificationNarrative(input: {
|
||||
}
|
||||
issues = validation.issues;
|
||||
} catch (error) {
|
||||
issues = [error instanceof Error ? error.name : "NarrativeOutputError"];
|
||||
issues = error instanceof z.ZodError
|
||||
? error.issues.map((issue) => `${issue.path.join(".") || "root"}:${issue.code}`)
|
||||
: [error instanceof Error ? error.name : "NarrativeOutputError"];
|
||||
}
|
||||
}
|
||||
const output = fallbackOutput(input.packet, input.phase);
|
||||
console.warn("[rectification-narrative-fallback]", JSON.stringify({
|
||||
phase: input.phase,
|
||||
modelId,
|
||||
attempts: 2,
|
||||
issues: boundedReceiptIssues(issues),
|
||||
}));
|
||||
return {
|
||||
narrative: output.narrative,
|
||||
output,
|
||||
|
||||
@@ -251,6 +251,43 @@ function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
|
||||
}));
|
||||
}
|
||||
|
||||
function narrativeConversationContext(input: Readonly<{
|
||||
latestUserText: string;
|
||||
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
}>) {
|
||||
const activeEvidence = effectiveLifeEventEvidence(input.allEvidence);
|
||||
const activeIds = new Set(activeEvidence.map((item) => item.id));
|
||||
return {
|
||||
latestUserText: input.latestUserText.trim().slice(0, 4_000),
|
||||
latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({
|
||||
dateLabel: item.dateLabel,
|
||||
summary: item.summary,
|
||||
domain: item.domain,
|
||||
})),
|
||||
eventLedger: input.allEvidence.slice(-40).map((item) => ({
|
||||
id: item.id,
|
||||
rawText: item.rawText,
|
||||
dateLabel: item.dateValue ?? "日期待补充",
|
||||
summary: visibleEvidenceSummary(item.eventSummary),
|
||||
domain: item.domain,
|
||||
extractionStatus: item.extractionStatus,
|
||||
active: activeIds.has(item.id),
|
||||
correctsEvidenceIds: [...(item.correctsEvidenceIds ?? [])],
|
||||
})),
|
||||
unresolvedEvidence: activeEvidence
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.slice(-20)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
rawText: item.rawText,
|
||||
summary: visibleEvidenceSummary(item.eventSummary),
|
||||
domain: item.domain,
|
||||
dateLabel: item.dateValue ?? "日期待补充",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const progressDomainLabels = {
|
||||
career: "事业",
|
||||
education: "学业",
|
||||
@@ -266,8 +303,10 @@ function evidenceProgressNarrative(input: Readonly<{
|
||||
previousCandidate: PrivateCandidateInput;
|
||||
packet: RectificationTechnicalPacket;
|
||||
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
scoreableEventCount: number;
|
||||
willContinue: boolean;
|
||||
authoredNarrative?: string | null;
|
||||
}>): string {
|
||||
const recorded = evidenceRecap(input.newEvidence);
|
||||
const acknowledgement = recorded.length === 0
|
||||
@@ -283,8 +322,7 @@ function evidenceProgressNarrative(input: Readonly<{
|
||||
: rangeChanged
|
||||
? `候选范围已从 ${previousStart ?? "原范围"}–${previousEnd ?? "原范围"} 更新为 ${nextStart}–${nextEnd}。`
|
||||
: `本轮已纳入 ${input.scoreableEventCount} 条可评分经历,但候选范围暂未稳定缩小;这不是提交失败。`;
|
||||
const suggested = input.packet.suggestedDomains
|
||||
.slice(0, 2)
|
||||
const suggested = nextEvidenceDomains(input.packet, input.allEvidence)
|
||||
.map((item) => progressDomainLabels[item.domain]);
|
||||
const nextStep = input.willContinue && suggested.length > 0
|
||||
? `下一步:请优先补充一件${suggested.join("或")}领域已经发生的事件,并选择大致年月。`
|
||||
@@ -294,12 +332,48 @@ function evidenceProgressNarrative(input: Readonly<{
|
||||
.map((item) => `${progressDomainLabels[item.domain]}事件用于比较 ${item.layer}`)
|
||||
.join(";")}。`
|
||||
: "";
|
||||
return [acknowledgement, progress, nextStep, differenceBasis]
|
||||
const authored = input.authoredNarrative?.trim();
|
||||
if (authored) return authored.slice(0, 12_000);
|
||||
return [acknowledgement, authored, progress, nextStep, differenceBasis]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.slice(0, 12_000);
|
||||
}
|
||||
|
||||
function unansweredEvidenceDomains(
|
||||
packet: RectificationTechnicalPacket,
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>,
|
||||
) {
|
||||
const answeredDomains = new Set(effectiveLifeEventEvidence(evidence)
|
||||
.filter((item) => item.extractionStatus !== "needs_clarification")
|
||||
.map((item) => item.domain));
|
||||
return packet.suggestedDomains.filter((item) => !answeredDomains.has(item.domain));
|
||||
}
|
||||
|
||||
function nextEvidenceDomains(
|
||||
packet: RectificationTechnicalPacket,
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>,
|
||||
) {
|
||||
const unanswered = unansweredEvidenceDomains(packet, evidence);
|
||||
return (unanswered.length > 0 ? unanswered : packet.suggestedDomains).slice(0, 2);
|
||||
}
|
||||
|
||||
function evidenceRequestForProgress(input: Readonly<{
|
||||
packet: RectificationTechnicalPacket;
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
willContinue: boolean;
|
||||
}>): RectificationNarrativeResult["output"]["evidenceRequest"] {
|
||||
if (!input.willContinue) return null;
|
||||
const suggested = nextEvidenceDomains(input.packet, input.evidence);
|
||||
if (suggested.length === 0) return null;
|
||||
const labels = suggested.map((item) => progressDomainLabels[item.domain]);
|
||||
return {
|
||||
domains: suggested.map((item) => item.domain),
|
||||
datePrecision: "month_preferred",
|
||||
prompt: `请说一件${labels.join("或")}方面已经发生的事,尽量写明哪一年、哪一月以及发生了什么。`,
|
||||
};
|
||||
}
|
||||
|
||||
function exactTechnicalReceipt(packet: RectificationTechnicalPacket) {
|
||||
const projected = projectRectificationTechnicalPacket(packet);
|
||||
return {
|
||||
@@ -410,7 +484,7 @@ function completedRangeTurn(
|
||||
turn: ConversationalRectificationTurn,
|
||||
reason: RangeCompletionReason,
|
||||
): ConversationalRectificationTurn {
|
||||
const suffix = `${rangeCompletionCopy(reason)} 本次校正已结束并保存当前候选范围;候选代表时间不会替换当前排盘时间。`;
|
||||
const suffix = `${rangeCompletionCopy(reason)} 当前证据没有收敛到可确认分钟,因此本次不计费,已退回暂扣点数。候选范围仅作记录,代表时间不会替换当前排盘时间。`;
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||||
...turn,
|
||||
status: "completed",
|
||||
@@ -505,6 +579,16 @@ function nonScoringTurn(input: {
|
||||
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
|
||||
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
|
||||
&& item.scoreable === false && item.dateValue !== null);
|
||||
const latestIncomplete = input.newEvidence
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.at(-1);
|
||||
const clarificationNarrative = latestIncomplete?.dateValue === null
|
||||
&& latestIncomplete.eventSummary !== "事件内容待补充"
|
||||
? `你提到“${visibleEvidenceSummary(latestIncomplete.eventSummary)}”,具体内容我已经记下了。它大致是什么年月?只记得年份也可以。`
|
||||
: latestIncomplete?.dateValue
|
||||
&& latestIncomplete.eventSummary === "事件内容待补充"
|
||||
? `我已经记下 ${latestIncomplete.dateValue} 这个时间。那时具体发生了什么重要事情?`
|
||||
: null;
|
||||
const correctionNarrative = input.correctionReset?.reason === "validation_fallback"
|
||||
? "这条更正已保存,原记录已经停止参与候选评分,候选范围也已重新计算。为避免只凭一次修订直接确认出生分钟,本轮先保持待验证;请继续补充另一件已经发生的真实经历。"
|
||||
: input.correctionReset?.reason === "direction_change"
|
||||
@@ -518,7 +602,8 @@ function nonScoringTurn(input: {
|
||||
? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。"
|
||||
: hasFuture
|
||||
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
|
||||
: "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
|
||||
: clarificationNarrative
|
||||
?? "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
|
||||
const status = input.correctionReset
|
||||
? "active" as const
|
||||
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
|
||||
@@ -765,6 +850,48 @@ export function createConversationalRectificationService(
|
||||
return extracted;
|
||||
}
|
||||
|
||||
function completeLatestClarification(input: Readonly<{
|
||||
command: CommandOf<"answer">;
|
||||
current: LoadedConversationalRectificationCase;
|
||||
extracted: readonly LifeEventEvidence[];
|
||||
}>): readonly LifeEventEvidence[] {
|
||||
if (input.command.correctsEvidenceId || input.extracted.length !== 1) return input.extracted;
|
||||
const pending = effectiveLifeEventEvidence(input.current.eventEvidence)
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.at(-1);
|
||||
const supplied = input.extracted[0];
|
||||
if (!pending || !supplied) return input.extracted;
|
||||
|
||||
const pendingHasSummary = pending.eventSummary !== "事件内容待补充";
|
||||
const suppliedHasSummary = supplied.eventSummary !== "事件内容待补充";
|
||||
const fillsMissingDate = pending.dateValue === null
|
||||
&& supplied.dateValue !== null
|
||||
&& pendingHasSummary
|
||||
&& !suppliedHasSummary;
|
||||
const fillsMissingSummary = pending.dateValue !== null
|
||||
&& supplied.dateValue === null
|
||||
&& !pendingHasSummary
|
||||
&& suppliedHasSummary;
|
||||
if (!fillsMissingDate && !fillsMissingSummary) return input.extracted;
|
||||
|
||||
const dateValue = supplied.dateValue ?? pending.dateValue;
|
||||
const summary = suppliedHasSummary ? supplied.eventSummary : pending.eventSummary;
|
||||
if (!dateValue || summary === "事件内容待补充") return input.extracted;
|
||||
const merged = extractLifeEventEvidence({
|
||||
rawText: `${dateValue} ${summary}`,
|
||||
sourceTurnId: input.command.actionId,
|
||||
asOfDate: ports.asOfDate(),
|
||||
correctsEvidenceId: pending.id,
|
||||
});
|
||||
if (merged.length !== 1) return input.extracted;
|
||||
return merged.map((item) => ({
|
||||
...item,
|
||||
rawText: `${pending.rawText}\n补充:${input.command.answer}`,
|
||||
domain: pending.domain === "other" ? item.domain : pending.domain,
|
||||
correctsEvidenceIds: [...item.correctsEvidenceIds],
|
||||
}));
|
||||
}
|
||||
|
||||
const service: ConversationalRectificationService = Object.freeze({
|
||||
importLegacyCase,
|
||||
async start(userId, rawCommand) {
|
||||
@@ -942,7 +1069,11 @@ export function createConversationalRectificationService(
|
||||
const current = await load(userId, command.caseId);
|
||||
requireMutable(current);
|
||||
const evidence = evidenceForDeclaredBirthDate(
|
||||
extractedEvidence(command),
|
||||
completeLatestClarification({
|
||||
command,
|
||||
current,
|
||||
extracted: extractedEvidence(command),
|
||||
}),
|
||||
current.declaredBirthInput.birthDate,
|
||||
);
|
||||
|
||||
@@ -1041,6 +1172,11 @@ export function createConversationalRectificationService(
|
||||
phase,
|
||||
packet: gatedPacket,
|
||||
generator: ports.narrativeGenerator,
|
||||
context: narrativeConversationContext({
|
||||
latestUserText: command.answer,
|
||||
allEvidence: [...current.eventEvidence, ...evidence],
|
||||
newEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
if (narrative.fallbackUsed) {
|
||||
const next = nonScoringTurn({
|
||||
@@ -1149,12 +1285,21 @@ export function createConversationalRectificationService(
|
||||
phase,
|
||||
packet: gatedPacket,
|
||||
generator: ports.narrativeGenerator,
|
||||
context: narrativeConversationContext({
|
||||
latestUserText: command.answer,
|
||||
allEvidence: [...current.eventEvidence, ...evidence],
|
||||
newEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
|
||||
const completionReason = rangeCompletionReason({
|
||||
packet: gatedPacket,
|
||||
scoreableEventCount: allScoreable.length,
|
||||
plateauCount,
|
||||
unansweredSuggestedDomainCount: unansweredEvidenceDomains(
|
||||
gatedPacket,
|
||||
[...current.eventEvidence, ...evidence],
|
||||
).length,
|
||||
});
|
||||
const narrativeWithProgress = {
|
||||
...narrative,
|
||||
@@ -1162,10 +1307,21 @@ export function createConversationalRectificationService(
|
||||
previousCandidate: current.privateCandidate,
|
||||
packet: gatedPacket,
|
||||
newEvidence: evidence,
|
||||
allEvidence: [...current.eventEvidence, ...evidence],
|
||||
scoreableEventCount: allScoreable.length,
|
||||
willContinue: completionReason === null
|
||||
&& gatedPacket.candidate.status !== "ready_for_confirmation",
|
||||
authoredNarrative: narrative.fallbackUsed ? null : narrative.narrative,
|
||||
}),
|
||||
output: {
|
||||
...narrative.output,
|
||||
evidenceRequest: evidenceRequestForProgress({
|
||||
packet: gatedPacket,
|
||||
evidence: [...current.eventEvidence, ...evidence],
|
||||
willContinue: completionReason === null
|
||||
&& gatedPacket.candidate.status !== "ready_for_confirmation",
|
||||
}),
|
||||
},
|
||||
} satisfies RectificationNarrativeResult;
|
||||
const privateCandidate = privateCandidateFromPacket({
|
||||
packet: gatedPacket,
|
||||
|
||||
@@ -372,7 +372,7 @@ export class ConversationalRectificationStore {
|
||||
async abandon(
|
||||
input: ConversationalRectificationTransitionInput,
|
||||
): Promise<StoredConversationalRectificationCase> {
|
||||
return this.requireTransition("abandon_conversational_rectification_case", input);
|
||||
return this.requireTransition("abandon_conversational_rectification_without_result", input);
|
||||
}
|
||||
|
||||
private async requireTransition(
|
||||
|
||||
@@ -41,6 +41,29 @@ export type SuggestedEvidenceDomain = {
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
export type RectificationTechniqueAuditRow = {
|
||||
readonly technique: string;
|
||||
readonly status: "used" | "partial" | "blocked" | "not_evaluated";
|
||||
readonly evidence: readonly string[];
|
||||
readonly boundary: string;
|
||||
};
|
||||
|
||||
export type RectificationExpertWorkflow = {
|
||||
readonly boundary: "not_auto_rectified";
|
||||
readonly candidateWindows: readonly {
|
||||
readonly startTime: string;
|
||||
readonly endTime: string;
|
||||
readonly status: "pending_validation" | "ready_for_confirmation";
|
||||
}[];
|
||||
readonly techniqueAuditTable: readonly RectificationTechniqueAuditRow[];
|
||||
readonly confirmationAllowed: boolean;
|
||||
readonly hardBlockers: readonly string[];
|
||||
readonly gates: Readonly<Record<string, {
|
||||
readonly status: "pass" | "fail" | "blocked" | "not_evaluated";
|
||||
readonly reason: string;
|
||||
}>>;
|
||||
};
|
||||
|
||||
export type RectificationTechnicalPacket = {
|
||||
readonly calculationVersion: string;
|
||||
readonly candidate: {
|
||||
@@ -79,6 +102,7 @@ export type RectificationTechnicalPacket = {
|
||||
readonly endDate: string;
|
||||
readonly scoreable: false;
|
||||
}[];
|
||||
readonly expertWorkflow?: RectificationExpertWorkflow;
|
||||
};
|
||||
|
||||
type PacketInput = {
|
||||
@@ -142,6 +166,90 @@ function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.trim().length > 0))];
|
||||
}
|
||||
|
||||
function buildExpertWorkflow(
|
||||
eventScore: CandidateResult | null,
|
||||
range: RectificationTechnicalPacket["candidate"]["range"],
|
||||
status: RectificationTechnicalPacket["candidate"]["status"],
|
||||
): RectificationExpertWorkflow {
|
||||
const receipt = eventScore?.techniqueReceipt;
|
||||
const row = (
|
||||
technique: string,
|
||||
used: boolean,
|
||||
evidence: readonly string[],
|
||||
boundary: string,
|
||||
unavailableStatus: RectificationTechniqueAuditRow["status"] = "not_evaluated",
|
||||
): RectificationTechniqueAuditRow => ({
|
||||
technique,
|
||||
status: used ? "used" : unavailableStatus,
|
||||
evidence,
|
||||
boundary,
|
||||
});
|
||||
const missingLayers = receipt?.missingLayers ?? [];
|
||||
const shadbalaPartial = missingLayers.some((layer) => layer.toLowerCase().includes("shadbala"));
|
||||
const strengthLayers = receipt?.auxiliaryLayers.filter((layer) => /shadbala|ashtakavarga/i.test(layer)) ?? [];
|
||||
return {
|
||||
boundary: "not_auto_rectified",
|
||||
candidateWindows: [{ ...range, status }],
|
||||
techniqueAuditTable: [
|
||||
row(
|
||||
"Vimshottari Dasha",
|
||||
receipt?.dashaTracks.some((track) => track.includes("vimshottari")) === true,
|
||||
receipt?.dashaTracks.filter((track) => track.includes("vimshottari")) ?? [],
|
||||
"用于已发生事件的时间匹配,不单独确认出生分钟。",
|
||||
),
|
||||
row(
|
||||
"Narayana Dasha",
|
||||
receipt?.dashaTracks.some((track) => track.includes("narayana")) === true,
|
||||
receipt?.dashaTracks.filter((track) => track.includes("narayana")) ?? [],
|
||||
"作为第二条时序轨交叉检查,不替代分钟稳定性门禁。",
|
||||
),
|
||||
row(
|
||||
"UL / A7 / A10",
|
||||
(receipt?.usedArudha.length ?? 0) > 0,
|
||||
receipt?.usedArudha ?? [],
|
||||
"只在对应关系或事业事件中参与候选比较。",
|
||||
),
|
||||
row(
|
||||
"Divisional charts",
|
||||
(receipt?.usedDivisionalCharts.length ?? 0) > 0,
|
||||
receipt?.usedDivisionalCharts ?? [],
|
||||
"分盘只用于区分候选范围,不能把范围中点当作准确分钟。",
|
||||
),
|
||||
{
|
||||
technique: "Shadbala / Ashtakavarga",
|
||||
status: shadbalaPartial ? "partial" : strengthLayers.length > 0 ? "used" : "not_evaluated",
|
||||
evidence: unique([...strengthLayers, ...missingLayers.filter((layer) => /shadbala|ashtakavarga/i.test(layer))]),
|
||||
boundary: shadbalaPartial
|
||||
? "仅使用已验证组件;缺失组件不得补算或包装成完整闭环。"
|
||||
: "仅作为辅助权重,不单独确认出生分钟。",
|
||||
},
|
||||
row(
|
||||
"Functional Benefic / Malefic",
|
||||
receipt?.auxiliaryLayers.includes("functional_benefic_malefic") === true,
|
||||
receipt?.auxiliaryLayers.filter((layer) => layer === "functional_benefic_malefic") ?? [],
|
||||
"仅用于解释事件与候选的相容性。",
|
||||
),
|
||||
{
|
||||
technique: "KP cusp / sub-lord",
|
||||
status: "blocked",
|
||||
evidence: [],
|
||||
boundary: "当前服务端生时校正评分合同未提供可审计的 KP cusp 结果,禁止声称已使用。",
|
||||
},
|
||||
{
|
||||
technique: "Minute confirmation",
|
||||
status: receipt?.confirmationAllowed === true ? "used" : "blocked",
|
||||
evidence: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
|
||||
boundary: receipt?.confirmationAllowed === true
|
||||
? "仍须用户明确确认后才能替换当前排盘时间。"
|
||||
: "公开 AA 分钟 holdout 与盲测门禁未通过前,不得自动确认分钟。",
|
||||
},
|
||||
],
|
||||
confirmationAllowed: receipt?.confirmationAllowed === true,
|
||||
hardBlockers: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
|
||||
gates: receipt?.gates ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function timeToMinute(value: string): number {
|
||||
const [hour = 0, minute = 0] = value.split(":").map(Number);
|
||||
return hour * 60 + minute;
|
||||
@@ -320,10 +428,11 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
|
||||
const partitionIds = unique(Object.values(input.candidateDifferences.scoringPartitions)
|
||||
.flatMap((partitions) => partitions.map((partition) => partition.partitionId)));
|
||||
|
||||
const candidateStatus = input.eventScore?.canApply ? "ready_for_confirmation" : "pending_validation";
|
||||
return {
|
||||
calculationVersion: input.consultation.calculationVersion,
|
||||
candidate: {
|
||||
status: input.eventScore?.canApply ? "ready_for_confirmation" : "pending_validation",
|
||||
status: candidateStatus,
|
||||
representativeTime,
|
||||
range,
|
||||
},
|
||||
@@ -349,6 +458,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
|
||||
suggestedDomains: domains.slice(0, 4),
|
||||
referenceIds: unique([...opportunityRefs, ...ruleRefs, ...layerRefs]),
|
||||
futureWindows: input.consultation.futureWindows.map((window) => ({ ...window, scoreable: false })),
|
||||
expertWorkflow: buildExpertWorkflow(input.eventScore, range, candidateStatus),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ConversationalRectificationTurn } from "./contracts.ts";
|
||||
|
||||
type EvidenceDomain = NonNullable<
|
||||
ConversationalRectificationTurn["evidenceRequest"]
|
||||
>["domains"][number];
|
||||
|
||||
const domainLabels = {
|
||||
career: "事业与身份",
|
||||
education: "学业与学习",
|
||||
finance: "收入与资产",
|
||||
health_pressure: "健康与重大压力",
|
||||
relocation: "搬迁与居住地",
|
||||
relationship: "重要关系",
|
||||
family: "家庭变化",
|
||||
other: "其他关键经历",
|
||||
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
|
||||
|
||||
function nextEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] {
|
||||
const provided = new Set(turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []));
|
||||
const requested = [...(turn.evidenceRequest?.domains ?? [])];
|
||||
const unanswered = requested.filter((domain) => !provided.has(domain));
|
||||
return (unanswered.length > 0 ? unanswered : requested).slice(0, 2);
|
||||
}
|
||||
|
||||
export function visibleRectificationNarrative(turn: ConversationalRectificationTurn): string {
|
||||
if (turn.narrative.trim()) return turn.narrative;
|
||||
|
||||
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 (latestEvidence.dateLabel === "日期待补充") {
|
||||
return `你提到“${latestEvidence.summary}”,具体内容我已经记下了。它大致是什么年月?只记得年份也可以。`;
|
||||
}
|
||||
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}。范围暂未变化不代表提交失败,我会结合后续经历继续比较相邻分钟。`,
|
||||
nextQuestion,
|
||||
].join("\n\n");
|
||||
}
|
||||
Reference in New Issue
Block a user