feat: complete minute birth-time rectification flow
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user