Files
Jyotisha/frontend/src/lib/rectification-surface-state.ts
T
Jesse_ChenandCursor 66f63c7643 fix(rectification): deliver range when dated discriminator pool is empty (BUG-651/652)
When dated choice probes are exhausted after the training gate, stop treating yearless D9/D10 cards as the next discriminator and persist a range carrier in the same answer transaction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 14:33:39 +08:00

416 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pure state for the rectification chat surface: what the entry, the
* transcript and the composer status show in each phase, and how a Case is
* hydrated (turns + snapshot) before the surface is revealed.
*
* Nothing here touches React. The chat component and the surface hook call
* these so the "is there anything to wait for, and what does the reader see
* while waiting" decisions are testable without a DOM.
*/
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
/**
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
* returns and before the surface is revealed. Past it the surface opens with
* whatever arrived and the composer status's retry path fills the rest. It is
* the home reveal budget: one constant, not two.
*/
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
/** Question recovery: one immediate refetch after a turn, then this many timed retries. */
export const RECTIFICATION_QUESTION_RETRY_LIMIT = 2;
export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
export const RECTIFICATION_COLLECT_WAITING_PLACEHOLDER = "再说一件带年月的事";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
export function isAbortError(caught: unknown): boolean {
return (caught instanceof DOMException || caught instanceof Error) && caught.name === "AbortError";
}
export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…";
export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600;
/** Live-row labels by the action that started the turn (task 1.4). */
export const RECTIFICATION_OPENING_LIVE_LABEL = "正在读取你的出生资料,准备第一个问题…";
export const RECTIFICATION_MESSAGE_LIVE_LABEL = "正在处理…";
export function rectificationInitialLiveLabel(
action: "opening" | "message" | "read_only",
continuationLabel?: string,
): string {
if (action === "opening") return RECTIFICATION_OPENING_LIVE_LABEL;
if (action === "read_only" && continuationLabel) return continuationLabel;
return RECTIFICATION_MESSAGE_LIVE_LABEL;
}
export type RectificationCaseSnapshotPayload = Readonly<{
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
step_state?: unknown;
question_source?: unknown;
next_user_action?: Readonly<{ id?: unknown }>;
interview?: Readonly<{
stop_reason?: unknown;
session_outcome?: unknown;
type?: unknown;
}>;
case?: Readonly<{
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
/**
* Both are already on the wire from the case route; declaring them here
* lets the timeline read the current search window and stage. No server
* change was needed.
*/
candidate_range?: unknown;
stage?: unknown;
}>;
turns?: unknown;
}>;
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
export function searchWindowFromSnapshot(value: unknown): readonly [string, string] | null {
if (!value || typeof value !== "object") return null;
const row = value as { start_time?: unknown; end_time?: unknown };
const start = typeof row.start_time === "string" ? row.start_time.trim() : "";
const end = typeof row.end_time === "string" ? row.end_time.trim() : "";
return start && end ? [start, end] : null;
}
export function caseStageFromSnapshot(value: unknown): "minute" | "block_scan" | null {
return value === "minute" || value === "block_scan" ? value : null;
}
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
type RawTurn = {
id?: unknown;
role?: unknown;
text?: unknown;
status?: unknown;
question?: unknown;
offer_result_id?: unknown;
receipt?: unknown;
};
type RawToolActivity = {
tool?: unknown;
status?: unknown;
methods?: unknown;
started_at?: unknown;
elapsed_ms?: unknown;
detail?: unknown;
};
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function parseToolActivities(value: unknown): NonNullable<NonNullable<PersistedRectificationTurn["receipt"]>["tool_activities"]> | undefined {
if (!Array.isArray(value)) return undefined;
return value.flatMap((item: RawToolActivity) => {
if (!item || typeof item !== "object" || typeof item.tool !== "string" || typeof item.status !== "string") return [];
return [{
tool: item.tool,
status: item.status,
methods: Array.isArray(item.methods) ? item.methods.map(String) : undefined,
started_at: typeof item.started_at === "string" ? item.started_at : null,
elapsed_ms: typeof item.elapsed_ms === "number" ? item.elapsed_ms : null,
detail: item.detail && typeof item.detail === "object" ? item.detail as Record<string, unknown> : null,
}];
});
}
/** The Case API's `turns` rows, normalised to what the chat renders. */
export function parsePersistedRectificationTurns(value: unknown): PersistedRectificationTurn[] {
if (!Array.isArray(value)) return [];
return value.map((turn: RawTurn) => {
const receipt = turn?.receipt && typeof turn.receipt === "object"
? turn.receipt as {
status?: unknown;
phases?: unknown;
tools?: unknown;
methods?: unknown;
skill_name?: unknown;
skill_version?: unknown;
tool_activities?: unknown;
answer_origin?: unknown;
}
: null;
const toolActivities = parseToolActivities(receipt?.tool_activities);
return {
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
question: turn?.question ?? null,
offer_result_id: typeof turn?.offer_result_id === "string" ? turn.offer_result_id : null,
receipt: receipt ? {
status: String(receipt.status ?? ""),
phases: stringList(receipt.phases),
tools: stringList(receipt.tools),
methods: stringList(receipt.methods),
skill_name: typeof receipt.skill_name === "string" ? receipt.skill_name : undefined,
skill_version: typeof receipt.skill_version === "string" ? receipt.skill_version : undefined,
...(toolActivities ? { tool_activities: toolActivities } : {}),
...(receipt.answer_origin === "host_fallback" ? { answer_origin: "host_fallback" as const } : {}),
} : null,
};
});
}
export type RectificationCaseHydration = Readonly<{
turns: PersistedRectificationTurn[];
snapshot: RectificationCaseSnapshotPayload | null;
/** False when the request failed or the deadline passed first. */
complete: boolean;
}>;
export function rectificationCaseHref(caseId: string, sessionId: string): string {
return `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`;
}
/**
* Turns and snapshot come from one Case read, so hydration is a single request
* raced against the reveal deadline. A late or failed read still resolves, with
* empty turns and no snapshot, so the caller can reveal and let the surface's
* own retry path fill in the rest.
*/
export async function hydrateRectificationCase(
caseId: string,
sessionId: string,
options: Readonly<{
timeoutMs?: number;
fetchImpl?: typeof fetch;
setTimeoutImpl?: (callback: () => void, delayMs: number) => unknown;
clearTimeoutImpl?: (handle: unknown) => void;
}> = {},
): Promise<RectificationCaseHydration> {
const timeoutMs = options.timeoutMs ?? RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? fetch;
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
let deadline: unknown;
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
deadline = schedule(() => resolve(incomplete), timeoutMs);
});
const read = (async (): Promise<RectificationCaseHydration> => {
try {
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) return incomplete;
const payload: unknown = await response.json().catch(() => null);
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
return {
turns: parsePersistedRectificationTurns(payload.turns),
snapshot: payload,
complete: true,
};
} catch {
return incomplete;
}
})();
try {
return await Promise.race([read, timeout]);
} finally {
cancel(deadline);
}
}
export type RectificationConversationState =
| "readonly"
| "busy"
| "opening"
| "empty"
| "conversation";
/**
* What the transcript area is doing when there are no rendered turns. `opening`
* means the first turn is about to be requested (server said so); `empty` means
* the Case has nothing to show and no automatic first turn — the reader needs a
* way to start.
*/
export function rectificationConversationState(input: Readonly<{
messageCount: number;
busy: boolean;
readonly: boolean;
shouldStartOpening: boolean;
openingStarted: boolean;
}>): RectificationConversationState {
if (input.readonly) return "readonly";
if (input.busy) return "busy";
if (input.messageCount > 0) return "conversation";
if (input.shouldStartOpening || input.openingStarted) return "opening";
return "empty";
}
export type RectificationQuestionGapState =
| "idle"
| "preparing"
| "unavailable"
| "verified_idle"
| "persisted_question"
| "collect_waiting";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
liveQuestionVisible: boolean;
/** The snapshot names no current question at all. */
questionMissing: boolean;
/** The server said the question could not be loaded (`question_source: "unavailable"`). */
questionLoadFailed: boolean;
/** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */
offerAwaitingReader?: boolean;
/** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */
nextUserActionId?: string | null;
/**
* Snapshot has a current question from a persisted focus, even if no
* settled assistant message carries it yet.
*/
questionPersisted?: boolean;
/** Training gate still closed; pool empty; keep the case open for another dated event. */
collectWaiting?: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
snapshotLoaded: boolean;
resumableCase: boolean;
retryAttempts: number;
retryLimit?: number;
}>;
/**
* The gap between a settled turn and its next question. While retries remain
* it is `preparing` (one live row, timed refetches); once they run out, or the
* server itself reports the question unavailable, it is `unavailable` with a
* repair button. A snapshot that never arrived (hydration timed out) is a gap
* too: the same retries fill it. Questions themselves live inside assistant
* messages, so a visible live question means there is no gap.
*/
export function rectificationQuestionGapState(input: RectificationQuestionGapInput): RectificationQuestionGapState {
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
if (input.readonly || input.busy || input.regenerating) return "idle";
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
// message carries live yet: both are a gap the next read may close.
return retryGate;
}
/** Whether the gap should run another timed snapshot refetch. */
export function rectificationQuestionRetryActive(state: RectificationQuestionGapState): boolean {
return state === "preparing";
}
export function interviewStopReasonFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
if (!payload) return null;
const interviewReason = payload.interview?.stop_reason;
if (typeof interviewReason === "string" && interviewReason.trim()) return interviewReason.trim();
const latest = payload.latest_result;
if (latest && typeof latest === "object" && !Array.isArray(latest)) {
const reason = (latest as { evidence_stop_reason?: unknown; stop_reason?: unknown }).evidence_stop_reason
?? (latest as { stop_reason?: unknown }).stop_reason;
if (typeof reason === "string" && reason.trim()) return reason.trim();
}
return null;
}
export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
if (!payload) return null;
const interviewOutcome = payload.interview?.session_outcome;
if (typeof interviewOutcome === "string" && interviewOutcome.trim()) return interviewOutcome.trim();
return null;
}
export function interviewCollectWaiting(input: Readonly<{
stopReason?: string | null;
sessionOutcome?: string | null;
questionMissing: boolean;
offeringCards?: boolean;
}>): boolean {
if (!input.questionMissing || input.offeringCards) return false;
if (input.sessionOutcome && input.sessionOutcome !== "collect_evidence") return false;
return input.stopReason === "insufficient_dated_events"
|| input.stopReason === "insufficient_domains"
|| input.stopReason === "insufficient_events";
}
export function contrastProbesFromReceipt(receipt: Readonly<Record<string, unknown>> | null | undefined): unknown {
const packet = receipt?.candidate_contrast_packet;
if (!packet || typeof packet !== "object" || Array.isArray(packet)) return null;
return (packet as { probes?: unknown }).probes ?? null;
}
/** Timeline range line. Never says 收窄. Ban the exhausted-deadend phrase. */
export function rectificationReadonlyRangeCopy(input: Readonly<{
range: readonly [string, string];
sessionOutcome?: string | null;
discriminatingEventProbes?: unknown;
contrastProbes?: unknown;
}>): string {
const outcome = input.sessionOutcome ?? "";
const delivery = outcome === "adopt_representative"
|| outcome === "provisional_range"
|| outcome === "provisional_range_user_stopped"
|| outcome === "completed_with_range"
|| outcome === "validated_range"
|| outcome === "awaiting_confirmation"
|| outcome === "exact_minute_confirmed";
const suffix = delivery
? "选择题已问完,下面是当前范围"
: outcome === "collect_evidence"
? "再说一件带年月的事就能继续"
: "还在核对";
return `目前范围 ${input.range[0]}${input.range[1]}${suffix}`;
}
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
export function rectificationAdoptingLabel(time: string): string {
return `正在采用 ${time}…`;
}
/** Board copy when no candidate result exists yet (task 1.3). */
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
clock: string | null;
lines: readonly string[];
}> {
if (!declaredTime) {
return { clock: null, lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"] };
}
return {
clock: declaredTime,
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
};
}
/** The declared birth minute from the profile, or null when only a period was given. */
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
const candidate = (profile.reportedTime || profile.time || "").trim();
return /^\d{1,2}:\d{2}$/.test(candidate) ? candidate : null;
}