Merge branch 'feat/conversational-birth-time-rectification'

# Conflicts:
#	.github/workflows/deploy-production.yml
#	frontend/src/app/api/account/route.ts
#	frontend/src/app/globals.css
#	frontend/src/app/page.tsx
#	frontend/tests/health-deployment.test.ts
This commit is contained in:
Jesse_Chen
2026-07-21 16:03:53 +08:00
103 changed files with 27355 additions and 395 deletions
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
answerDynamicBirthTimeChoice,
confirmDynamicBirthTimeCandidate,
confirmBirthTimeEvidenceDraft,
draftBirthTimeEvidence,
finishBirthTimeRectification,
@@ -190,6 +191,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
const resultId = turn?.candidateResult?.resultId;
const winner = turn?.candidateResult?.winningSegment;
if (!turn || !resultId || winner?.representativeTime !== time) return;
if (turn.journeyProtocol === "dynamic-choice-v2") return;
const release = claimMutation(busy);
if (release === null) return;
setPending(true);
@@ -226,8 +228,10 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
(actionId) => saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId }),
));
const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
turn, "confirm_guided_candidate", [resultId, time],
(actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
turn, turn.journeyProtocol === "dynamic-choice-v2" ? "confirm_dynamic_candidate" : "confirm_guided_candidate", [resultId, time],
(actionId) => turn.journeyProtocol === "dynamic-choice-v2"
? confirmDynamicBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time })
: confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
));
const selectOption = (optionId: string) => operate((turn) => {
if (turn.journeyProtocol !== "dynamic-choice-v2"
@@ -0,0 +1,429 @@
"use client";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
createConversationalRectificationActionRegistry,
sendConversationalRectificationCommand,
type ConversationalRectificationActionIdentity,
} from "../lib/conversational-rectification/client.ts";
import type {
ConversationalRectificationCommand,
ConversationalRectificationTurn,
} from "../lib/conversational-rectification/contracts.ts";
type EvidenceDomain = NonNullable<
ConversationalRectificationTurn["evidenceRequest"]
>["domains"][number];
type EvidenceRecapEntry = ConversationalRectificationTurn["evidenceRecap"][number];
export type ConversationalRectificationControllerSnapshot = Readonly<{
turn: ConversationalRectificationTurn | null;
draft: string;
selectedDomain: EvidenceDomain | null;
correctionTarget: EvidenceRecapEntry | null;
pending: boolean;
error: string;
}>;
type MutationResult = Promise<ConversationalRectificationTurn | null>;
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
getSnapshot(): ConversationalRectificationControllerSnapshot;
subscribe(listener: () => void): () => void;
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void;
setDraft(value: string): void;
selectDomain(domain: EvidenceDomain | null): void;
beginEvidenceCorrection(evidenceId: string): void;
cancelEvidenceCorrection(): void;
start(pendingConsultationQuestion?: string | null): MutationResult;
resume(): MutationResult;
answer(domain?: EvidenceDomain): MutationResult;
pause(): MutationResult;
abandon(): MutationResult;
confirm(time?: string): MutationResult;
}>;
type ControllerInput = Readonly<{
initialTurn?: ConversationalRectificationTurn | null;
send?: (command: ConversationalRectificationCommand) => Promise<ConversationalRectificationTurn>;
createActionId?: () => string;
onTurn?: (turn: ConversationalRectificationTurn) => void;
onPendingChange?: (pending: boolean) => void;
}>;
type Mutation = Readonly<{
identity: ConversationalRectificationActionIdentity;
command(actionId: string): ConversationalRectificationCommand;
clearDraftOnSuccess?: boolean;
}>;
type ActiveMutation = Readonly<{
caseContext: number;
token: symbol;
promise: MutationResult;
}>;
function createLatestControllerInput(initial: ControllerInput) {
let current = initial;
return {
update(next: ControllerInput) {
current = next;
},
send(command: ConversationalRectificationCommand) {
return (current.send ?? sendConversationalRectificationCommand)(command);
},
onTurn(turn: ConversationalRectificationTurn) {
current.onTurn?.(turn);
},
onPendingChange(pending: boolean) {
current.onPendingChange?.(pending);
},
};
}
function displayError(error: unknown): string {
return error instanceof ConversationalRectificationRequestError
? error.message
: CONVERSATIONAL_RECTIFICATION_UNAVAILABLE;
}
function staleTurn(error: unknown): boolean {
return error instanceof ConversationalRectificationRequestError
&& error.status === 409
&& error.code === "stale_turn";
}
export function createConversationalRectificationController(
input: ControllerInput = {},
): ConversationalRectificationController {
const send = input.send ?? sendConversationalRectificationCommand;
const registry = createConversationalRectificationActionRegistry(input.createActionId);
const listeners = new Set<() => void>();
let snapshot: ConversationalRectificationControllerSnapshot = {
turn: input.initialTurn ?? null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
};
let activeMutation: ActiveMutation | null = null;
let caseContext = 0;
const publish = (next: ConversationalRectificationControllerSnapshot) => {
snapshot = next;
for (const listener of listeners) listener();
};
const patch = (next: Partial<ConversationalRectificationControllerSnapshot>) => {
publish({ ...snapshot, ...next });
};
const setPending = (pending: boolean) => {
if (snapshot.pending === pending) return;
patch({ pending });
try {
input.onPendingChange?.(pending);
} catch {
// Parent locks are observational and cannot change durable mutation state.
}
};
const acceptTurn = (
turn: ConversationalRectificationTurn,
clearDraft: boolean,
expectedCaseContext: number,
) => {
const current = snapshot.turn;
if (caseContext !== expectedCaseContext) return turn;
if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn;
const selectedDomain = clearDraft
? null
: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
: null;
const correctionTarget = clearDraft
? null
: snapshot.correctionTarget
? turn.evidenceRecap.find((entry) => entry.id === snapshot.correctionTarget?.id) ?? null
: null;
patch({
turn,
error: "",
selectedDomain,
correctionTarget,
...(clearDraft ? { draft: "" } : {}),
});
try {
input.onTurn?.(turn);
} catch {
// A consumer callback is observational. It must never turn a durable success into a failure.
}
return turn;
};
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
caseId: turn.caseId,
turnVersion: turn.turnVersion,
operation: "resume",
payload: {},
}, (actionId) => send({
type: "resume",
caseId: turn.caseId,
actionId,
turnVersion: turn.turnVersion,
}));
const run = (mutation: Mutation): MutationResult => {
if (activeMutation?.caseContext === caseContext) return activeMutation.promise;
patch({ error: "" });
setPending(true);
const turnAtStart = snapshot.turn;
const caseContextAtStart = caseContext;
const mutationToken = Symbol("conversational-rectification-mutation");
const ownsCurrentContext = () => caseContext === caseContextAtStart
&& activeMutation?.token === mutationToken;
const operation = registry.run(
mutation.identity,
(actionId) => send(mutation.command(actionId)),
).then((turn) => acceptTurn(
turn,
mutation.clearDraftOnSuccess === true,
caseContextAtStart,
))
.catch(async (error: unknown) => {
if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) {
try {
const recovered = await recoverLatest(turnAtStart);
return acceptTurn(recovered, false, caseContextAtStart);
} catch (recoveryError) {
if (ownsCurrentContext()) patch({ error: displayError(recoveryError) });
throw recoveryError;
}
}
if (ownsCurrentContext()) patch({ error: displayError(error) });
throw error;
})
.finally(() => {
if (activeMutation?.token !== mutationToken) return;
activeMutation = null;
if (caseContext === caseContextAtStart) setPending(false);
});
activeMutation = {
caseContext: caseContextAtStart,
token: mutationToken,
promise: operation,
};
return operation;
};
const activeMutationForCurrentContext = () => (
activeMutation?.caseContext === caseContext ? activeMutation.promise : null
);
const currentTurn = () => snapshot.turn;
const currentMutation = (
operation: Exclude<ConversationalRectificationCommand["type"], "start">,
payload: unknown,
command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand,
clearDraftOnSuccess = false,
): MutationResult => {
const turn = currentTurn();
if (!turn) return Promise.resolve(null);
return run({
identity: {
caseId: turn.caseId,
turnVersion: turn.turnVersion,
operation,
payload,
},
command: (actionId) => command(turn, actionId),
clearDraftOnSuccess,
});
};
const controller = {
get turn() { return snapshot.turn; },
get draft() { return snapshot.draft; },
get selectedDomain() { return snapshot.selectedDomain; },
get correctionTarget() { return snapshot.correctionTarget; },
get pending() { return snapshot.pending; },
get error() { return snapshot.error; },
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) {
const current = snapshot.turn;
if (turn === null) {
if (current === null) return;
caseContext += 1;
activeMutation = null;
try {
input.onPendingChange?.(false);
} catch {
// Parent locks are observational.
}
patch({
turn: null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
});
return;
}
if (current === null || current.caseId !== turn.caseId) {
caseContext += 1;
activeMutation = null;
try {
input.onPendingChange?.(false);
} catch {
// Parent locks are observational.
}
patch({
turn,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
});
return;
}
if (turn.turnVersion <= current.turnVersion) return;
patch({
turn,
error: "",
selectedDomain: snapshot.selectedDomain
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
: null,
correctionTarget: snapshot.correctionTarget
? turn.evidenceRecap.find((entry) => entry.id === snapshot.correctionTarget?.id) ?? null
: null,
});
},
setDraft(value: string) {
patch({ draft: value });
},
selectDomain(domain: EvidenceDomain | null) {
patch({ selectedDomain: domain });
},
beginEvidenceCorrection(evidenceId: string) {
const target = snapshot.turn?.evidenceRecap.find((entry) => entry.id === evidenceId);
if (!target || !snapshot.turn?.actions.includes("answer")) return;
patch({
correctionTarget: target,
// Keep the old fact visible in the correction banner, but out of the new raw evidence.
// Otherwise its old date can make an appended replacement date look ambiguous.
draft: "",
});
},
cancelEvidenceCorrection() {
patch({ correctionTarget: null, draft: "" });
},
start(pendingConsultationQuestion: string | null = null) {
return run({
identity: {
caseId: "new-case",
turnVersion: 0,
operation: "start",
payload: { pendingConsultationQuestion },
},
command: (actionId) => ({ type: "start", actionId, pendingConsultationQuestion }),
});
},
resume() {
return currentMutation("resume", {}, (turn, actionId) => ({
type: "resume",
caseId: turn.caseId,
actionId,
turnVersion: turn.turnVersion,
}));
},
answer(domain: EvidenceDomain | undefined = snapshot.selectedDomain ?? undefined) {
const turn = currentTurn();
const answer = snapshot.draft.trim();
if (!turn || !answer || !turn.actions.includes("answer")) return Promise.resolve(turn);
const correctsEvidenceId = snapshot.correctionTarget?.id;
const payload = {
answer,
...(domain ? { domain } : {}),
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
};
return currentMutation("answer", payload, (current, actionId) => ({
type: "answer",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
answer,
...(domain ? { domain } : {}),
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
}), true);
},
pause() {
const turn = currentTurn();
if (!turn?.actions.includes("pause")) {
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
}
return currentMutation("pause", {}, (current, actionId) => ({
type: "pause",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
}));
},
abandon() {
const turn = currentTurn();
if (!turn?.actions.includes("abandon")) {
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
}
return currentMutation("abandon", {}, (current, actionId) => ({
type: "abandon",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
}));
},
confirm(time?: string) {
const turn = currentTurn();
const candidateTime = time ?? turn?.candidate.representativeTime ?? null;
if (!turn?.actions.includes("confirm") || turn.candidate.status !== "ready_for_confirmation"
|| !candidateTime) {
return activeMutationForCurrentContext() ?? Promise.resolve(turn);
}
return currentMutation("confirm", { time: candidateTime }, (current, actionId) => ({
type: "confirm",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
time: candidateTime,
}));
},
} satisfies ConversationalRectificationController;
return controller;
}
export function useConversationalRectification(
input: ControllerInput = {},
): ConversationalRectificationController {
const [latestInput] = useState(() => createLatestControllerInput(input));
const [controller] = useState(() => createConversationalRectificationController({
initialTurn: input.initialTurn,
createActionId: input.createActionId,
send: latestInput.send,
onTurn: latestInput.onTurn,
onPendingChange: latestInput.onPendingChange,
}));
const snapshot = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
useLayoutEffect(() => {
latestInput.update(input);
}, [input, latestInput]);
useEffect(() => {
controller.synchronizeInitialTurn(input.initialTurn ?? null);
}, [controller, input.initialTurn]);
return { ...controller, ...snapshot };
}