feat: complete minute birth-time rectification flow

This commit is contained in:
Jesse_Chen
2026-07-25 01:15:48 +08:00
parent f90aba217f
commit 3ca30ed7de
93 changed files with 8347 additions and 1646 deletions
@@ -5,13 +5,12 @@ import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
createConversationalRectificationActionRegistry,
conversationalRectificationHistoryForTurn,
sendConversationalRectificationCommand,
type ConversationalRectificationActionIdentity,
type ConversationalRectificationStreamOptions,
} from "../lib/conversational-rectification/client.ts";
import type {
ConversationalRectificationCommand,
ConversationalRectificationResponse,
ConversationalRectificationTurn,
} from "../lib/conversational-rectification/contracts.ts";
@@ -26,46 +25,66 @@ export type ConversationalRectificationMessage = Readonly<{
renderKey: string;
}>;
export type ConversationalRectificationStoredMessage = Readonly<{
role: "assistant" | "user";
text: string;
}>;
function assistantText(turn: ConversationalRectificationTurn): string {
return turn.narrative.trim();
}
function initialMessages(turn: ConversationalRectificationResponse | null): ConversationalRectificationMessage[] {
function initialMessages(turn: ConversationalRectificationTurn | null): ConversationalRectificationMessage[] {
if (!turn) return [];
if (turn.messageHistory?.length) {
return turn.messageHistory.flatMap((entry) => [
...(entry.userMessage ? [{
role: "user" as const,
text: entry.userMessage,
renderKey: `user-turn-${entry.turnVersion}`,
}] : []),
{
role: "assistant" as const,
text: entry.narrative.trim(),
renderKey: `assistant-turn-${entry.turnVersion}`,
},
]);
if (turn.evidenceRecap.length === 0) {
return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }];
}
return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }];
const latestEvidence = turn.evidenceRecap.at(-1);
return [
...(latestEvidence ? [{
role: "user" as const,
text: `${latestEvidence.dateLabel} · ${latestEvidence.summary}${latestEvidence.isCorrection ? "(已修订)" : ""}`,
renderKey: `user-${latestEvidence.id}`,
}] : []),
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
];
}
function restoredMessages(
turn: ConversationalRectificationTurn | null,
messages?: readonly ConversationalRectificationStoredMessage[],
): ConversationalRectificationMessage[] {
const durableMessages = messages?.length
? messages
: turn
? conversationalRectificationHistoryForTurn(turn)
: [];
if (!durableMessages.length) return initialMessages(turn);
return durableMessages.map((message, index) => ({
...message,
renderKey: `persisted-${turn?.caseId ?? "unbound"}-${index}`,
}));
}
export type ConversationalRectificationControllerSnapshot = Readonly<{
turn: ConversationalRectificationResponse | null;
turn: ConversationalRectificationTurn | null;
messages?: readonly ConversationalRectificationMessage[];
draft: string;
selectedDomain: EvidenceDomain | null;
correctionTarget: EvidenceRecapEntry | null;
pending: boolean;
streamingAssistantText?: string;
error: string;
}>;
type MutationResult = Promise<ConversationalRectificationResponse | null>;
type MutationResult = Promise<ConversationalRectificationTurn | null>;
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
getSnapshot(): ConversationalRectificationControllerSnapshot;
subscribe(listener: () => void): () => void;
synchronizeInitialTurn(turn: ConversationalRectificationResponse | null): void;
synchronizeInitialTurn(
turn: ConversationalRectificationTurn | null,
messages?: readonly ConversationalRectificationStoredMessage[],
): void;
setDraft(value: string): void;
selectDomain(domain: EvidenceDomain | null): void;
beginEvidenceCorrection(evidenceId: string): void;
@@ -73,19 +92,23 @@ export type ConversationalRectificationController = ConversationalRectificationC
start(pendingConsultationQuestion?: string | null): MutationResult;
resume(): MutationResult;
answer(domain?: EvidenceDomain, answerOverride?: string): MutationResult;
regenerate(): MutationResult;
pause(): MutationResult;
abandon(): MutationResult;
confirm(time?: string): MutationResult;
}>;
type ControllerInput = Readonly<{
initialTurn?: ConversationalRectificationResponse | null;
send?: (
command: ConversationalRectificationCommand,
options?: ConversationalRectificationStreamOptions,
) => Promise<ConversationalRectificationResponse>;
initialTurn?: ConversationalRectificationTurn | null;
initialMessages?: readonly ConversationalRectificationStoredMessage[];
modelId?: string;
getModelId?: () => string | undefined;
send?: (command: ConversationalRectificationCommand) => Promise<ConversationalRectificationTurn>;
createActionId?: () => string;
onTurn?: (turn: ConversationalRectificationResponse) => void;
onTurn?: (
turn: ConversationalRectificationTurn,
messages: readonly ConversationalRectificationMessage[],
) => void;
onPendingChange?: (pending: boolean) => void;
}>;
@@ -94,6 +117,7 @@ type Mutation = Readonly<{
command(actionId: string): ConversationalRectificationCommand;
clearDraftOnSuccess?: boolean;
userMessage?: string;
replaceLatestAssistant?: boolean;
}>;
type ActiveMutation = Readonly<{
@@ -108,11 +132,17 @@ function createLatestControllerInput(initial: ControllerInput) {
update(next: ControllerInput) {
current = next;
},
send(command: ConversationalRectificationCommand, options?: ConversationalRectificationStreamOptions) {
return (current.send ?? sendConversationalRectificationCommand)(command, options);
modelId() {
return current.modelId;
},
onTurn(turn: ConversationalRectificationResponse) {
current.onTurn?.(turn);
send(command: ConversationalRectificationCommand) {
return (current.send ?? sendConversationalRectificationCommand)(command);
},
onTurn(
turn: ConversationalRectificationTurn,
messages: readonly ConversationalRectificationMessage[],
) {
current.onTurn?.(turn, messages);
},
onPendingChange(pending: boolean) {
current.onPendingChange?.(pending);
@@ -136,16 +166,16 @@ export function createConversationalRectificationController(
input: ControllerInput = {},
): ConversationalRectificationController {
const send = input.send ?? sendConversationalRectificationCommand;
const selectedModelId = () => (input.getModelId?.() ?? input.modelId)?.trim() || undefined;
const registry = createConversationalRectificationActionRegistry(input.createActionId);
const listeners = new Set<() => void>();
let snapshot: ConversationalRectificationControllerSnapshot = {
turn: input.initialTurn ?? null,
messages: initialMessages(input.initialTurn ?? null),
messages: restoredMessages(input.initialTurn ?? null, input.initialMessages),
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
streamingAssistantText: "",
error: "",
};
let activeMutation: ActiveMutation | null = null;
@@ -168,14 +198,36 @@ export function createConversationalRectificationController(
}
};
const acceptTurn = (
turn: ConversationalRectificationResponse,
turn: ConversationalRectificationTurn,
clearDraft: boolean,
expectedCaseContext: number,
userMessage?: string,
replaceLatestAssistant = false,
) => {
const current = snapshot.turn;
if (caseContext !== expectedCaseContext) return turn;
if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn;
const durableMessages = conversationalRectificationHistoryForTurn(turn);
const restoredDurableMessages = durableMessages.length > 0
? restoredMessages(turn, durableMessages)
: [];
if (current?.caseId === turn.caseId && current.turnVersion > turn.turnVersion) return turn;
if (current?.caseId === turn.caseId && current.turnVersion === turn.turnVersion) {
const latestDurableAssistant = durableMessages.findLast((message) => message.role === "assistant");
const repairsCurrentTranscript = restoredDurableMessages.length > (snapshot.messages?.length ?? 0)
&& latestDurableAssistant?.text.trim() === assistantText(current);
if (!repairsCurrentTranscript) return turn;
patch({
messages: restoredDurableMessages,
error: "",
...(clearDraft ? { draft: "" } : {}),
});
try {
input.onTurn?.(current, restoredDurableMessages);
} catch {
// A consumer callback is observational. It must never turn a durable success into a failure.
}
return turn;
}
const selectedDomain = clearDraft
? null
: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
@@ -186,29 +238,46 @@ export function createConversationalRectificationController(
: snapshot.correctionTarget
? turn.evidenceRecap.find((entry) => entry.id === snapshot.correctionTarget?.id) ?? null
: null;
const nextMessages = restoredDurableMessages.length > 0
? restoredDurableMessages
: replaceLatestAssistant
? (() => {
const messages = [...(snapshot.messages ?? [])];
const lastAssistantIndex = messages.findLastIndex((message) => message.role === "assistant");
const replacement = {
role: "assistant" as const,
text: assistantText(turn),
renderKey: `assistant-${turn.turnVersion}`,
};
if (lastAssistantIndex < 0) return [...messages, replacement];
messages[lastAssistantIndex] = replacement;
return messages;
})()
: userMessage
? [
...(snapshot.messages ?? []),
{ role: "user" as const, text: userMessage, renderKey: `user-${turn.turnVersion}` },
{ role: "assistant" as const, text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
]
: current === null || current.caseId !== turn.caseId
? initialMessages(turn)
: snapshot.messages ?? [];
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,
messages: nextMessages,
error: "",
streamingAssistantText: "",
selectedDomain,
correctionTarget,
...(clearDraft ? { draft: "" } : {}),
});
try {
input.onTurn?.(turn);
input.onTurn?.(turn, nextMessages);
} catch {
// A consumer callback is observational. It must never turn a durable success into a failure.
}
return turn;
};
const recoverLatest = async (turn: ConversationalRectificationResponse) => registry.run({
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
caseId: turn.caseId,
turnVersion: turn.turnVersion,
operation: "resume",
@@ -221,7 +290,7 @@ export function createConversationalRectificationController(
}));
const run = (mutation: Mutation): MutationResult => {
if (activeMutation?.caseContext === caseContext) return activeMutation.promise;
patch({ error: "", streamingAssistantText: "" });
patch({ error: "" });
setPending(true);
const turnAtStart = snapshot.turn;
const caseContextAtStart = caseContext;
@@ -230,17 +299,13 @@ export function createConversationalRectificationController(
&& activeMutation?.token === mutationToken;
const operation = registry.run(
mutation.identity,
(actionId) => send(mutation.command(actionId), {
onNarrativeDelta(text) {
if (!ownsCurrentContext()) return;
patch({ streamingAssistantText: (snapshot.streamingAssistantText ?? "") + text });
},
}),
(actionId) => send(mutation.command(actionId)),
).then((turn) => acceptTurn(
turn,
mutation.clearDraftOnSuccess === true,
caseContextAtStart,
mutation.userMessage,
mutation.replaceLatestAssistant === true,
))
.catch(async (error: unknown) => {
if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) {
@@ -248,20 +313,17 @@ export function createConversationalRectificationController(
const recovered = await recoverLatest(turnAtStart);
return acceptTurn(recovered, false, caseContextAtStart);
} catch (recoveryError) {
if (ownsCurrentContext()) patch({ error: displayError(recoveryError), streamingAssistantText: "" });
if (ownsCurrentContext()) patch({ error: displayError(recoveryError) });
throw recoveryError;
}
}
if (ownsCurrentContext()) patch({ error: displayError(error), streamingAssistantText: "" });
if (ownsCurrentContext()) patch({ error: displayError(error) });
throw error;
})
.finally(() => {
if (activeMutation?.token !== mutationToken) return;
activeMutation = null;
if (caseContext === caseContextAtStart) {
patch({ streamingAssistantText: "" });
setPending(false);
}
if (caseContext === caseContextAtStart) setPending(false);
});
activeMutation = {
caseContext: caseContextAtStart,
@@ -277,9 +339,10 @@ export function createConversationalRectificationController(
const currentMutation = (
operation: Exclude<ConversationalRectificationCommand["type"], "start">,
payload: unknown,
command: (turn: ConversationalRectificationResponse, actionId: string) => ConversationalRectificationCommand,
command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand,
clearDraftOnSuccess = false,
userMessage?: string,
replaceLatestAssistant = false,
): MutationResult => {
const turn = currentTurn();
if (!turn) return Promise.resolve(null);
@@ -293,6 +356,7 @@ export function createConversationalRectificationController(
command: (actionId) => command(turn, actionId),
clearDraftOnSuccess,
userMessage,
replaceLatestAssistant,
});
};
@@ -303,14 +367,16 @@ export function createConversationalRectificationController(
get selectedDomain() { return snapshot.selectedDomain; },
get correctionTarget() { return snapshot.correctionTarget; },
get pending() { return snapshot.pending; },
get streamingAssistantText() { return snapshot.streamingAssistantText; },
get error() { return snapshot.error; },
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
synchronizeInitialTurn(turn: ConversationalRectificationResponse | null) {
synchronizeInitialTurn(
turn: ConversationalRectificationTurn | null,
messages?: readonly ConversationalRectificationStoredMessage[],
) {
const current = snapshot.turn;
if (turn === null) {
if (current === null) return;
@@ -328,7 +394,6 @@ export function createConversationalRectificationController(
selectedDomain: null,
correctionTarget: null,
pending: false,
streamingAssistantText: "",
error: "",
});
return;
@@ -343,27 +408,30 @@ export function createConversationalRectificationController(
}
patch({
turn,
messages: initialMessages(turn),
messages: restoredMessages(turn, messages),
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
streamingAssistantText: "",
error: "",
});
return;
}
if (turn.turnVersion <= current.turnVersion) return;
if (turn.turnVersion <= current.turnVersion) {
const restored = messages?.length ? restoredMessages(turn, messages) : [];
if (turn.turnVersion === current.turnVersion
&& restored.length > (snapshot.messages?.length ?? 0)) {
patch({ messages: restored });
}
return;
}
patch({
turn,
messages: turn.messageHistory?.length
? initialMessages(turn)
: [
...(snapshot.messages ?? []),
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
],
messages: [
...(snapshot.messages ?? []),
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
],
error: "",
streamingAssistantText: "",
selectedDomain: snapshot.selectedDomain
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
@@ -393,14 +461,20 @@ export function createConversationalRectificationController(
patch({ correctionTarget: null, draft: "" });
},
start(pendingConsultationQuestion: string | null = null) {
const modelId = selectedModelId();
return run({
identity: {
caseId: "new-case",
turnVersion: 0,
operation: "start",
payload: { pendingConsultationQuestion },
payload: { pendingConsultationQuestion, ...(modelId ? { modelId } : {}) },
},
command: (actionId) => ({ type: "start", actionId, pendingConsultationQuestion }),
command: (actionId) => ({
type: "start",
actionId,
pendingConsultationQuestion,
...(modelId ? { modelId } : {}),
}),
});
},
resume() {
@@ -419,8 +493,10 @@ export function createConversationalRectificationController(
const answer = (answerOverride ?? snapshot.draft).trim();
if (!turn || !answer || !turn.actions.includes("answer")) return Promise.resolve(turn);
const correctsEvidenceId = snapshot.correctionTarget?.id;
const modelId = selectedModelId();
const payload = {
answer,
...(modelId ? { modelId } : {}),
...(domain ? { domain } : {}),
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
};
@@ -430,10 +506,25 @@ export function createConversationalRectificationController(
actionId,
turnVersion: current.turnVersion,
answer,
...(modelId ? { modelId } : {}),
...(domain ? { domain } : {}),
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
}), true, answer);
},
regenerate() {
const turn = currentTurn();
if (!turn?.actions.includes("answer")) {
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
}
const modelId = selectedModelId();
return currentMutation("regenerate", modelId ? { modelId } : {}, (current, actionId) => ({
type: "regenerate",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
...(modelId ? { modelId } : {}),
}), false, undefined, true);
},
pause() {
const turn = currentTurn();
if (!turn?.actions.includes("pause")) {
@@ -484,7 +575,9 @@ export function useConversationalRectification(
const [latestInput] = useState(() => createLatestControllerInput(input));
const [controller] = useState(() => createConversationalRectificationController({
initialTurn: input.initialTurn,
initialMessages: input.initialMessages,
createActionId: input.createActionId,
getModelId: () => latestInput.modelId(),
send: latestInput.send,
onTurn: latestInput.onTurn,
onPendingChange: latestInput.onPendingChange,
@@ -498,7 +591,7 @@ export function useConversationalRectification(
latestInput.update(input);
}, [input, latestInput]);
useEffect(() => {
controller.synchronizeInitialTurn(input.initialTurn ?? null);
}, [controller, input.initialTurn]);
controller.synchronizeInitialTurn(input.initialTurn ?? null, input.initialMessages);
}, [controller, input.initialMessages, input.initialTurn]);
return { ...controller, ...snapshot };
}