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>
416 lines
17 KiB
TypeScript
416 lines
17 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|