Files
Jyotisha/frontend/src/lib/rectification-surface-state.ts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

547 lines
22 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";
import { RECTIFICATION_TURN_PROGRESS_LABELS } from "./rectification-activity-labels.ts";
import { sessionOutcomeAllowsDelivery } from "./rectification-agentic/core/rectification-decision.ts";
import {
boardDeclaredTimeLine,
normalizeBirthTimeSource,
type ProvenanceSource,
} from "./rectification-agentic/birth-time-provenance.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_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 = "再说一件带年月的事";
/** D1 (2026-09-29): after delivery the composer no longer asks for more events (BUG-1084). */
export const RECTIFICATION_DELIVERED_PLACEHOLDER = "对结果有疑问,直接问就行…";
/** Default composer line: answer or ask; never an invitation to keep listing events. */
export const RECTIFICATION_COMPOSER_DEFAULT_PLACEHOLDER = "回答上面的问题,或直接问我…";
export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范围在上面,对不上可以改选。";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
export const RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY = "这一问还没读到。";
export const RECTIFICATION_SNAPSHOT_RETRY_LABEL = "重新读取";
/** A tapped option that did not reach the server (BUG-1046): the card is tappable again. */
export const RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY = "这次没提交上,请再点一次。";
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 async function recompareHistoricalResult(input: {
canRecompare: boolean;
historyReadonly: boolean;
busy: boolean;
send: (action: "message", text: string) => Promise<void>;
}): Promise<void> {
if (!input.canRecompare || input.historyReadonly || input.busy) return;
await input.send("message", "请按当前算法重新比较候选。");
}
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;
// A typed answer shows its first stage line the moment it is sent (BUG-1047 D3).
if (action === "message") return RECTIFICATION_TURN_PROGRESS_LABELS.received;
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<{
result_notice?: string | null;
result_identity?: Readonly<{ can_recompare?: boolean }>;
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;
birth_time_source?: 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 RectificationSnapshotFailure = Readonly<{
status: number | "network" | "timeout";
}>;
export type RectificationCaseHydration = Readonly<{
turns: PersistedRectificationTurn[];
snapshot: RectificationCaseSnapshotPayload | null;
/** False when the request failed or the deadline passed first. */
complete: boolean;
failure?: RectificationSnapshotFailure;
}>;
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,
failure: { status: "timeout" },
};
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 { turns: [], snapshot: null, complete: false, failure: { status: response.status } };
}
const payload: unknown = await response.json().catch(() => null);
if (!isRectificationCaseSnapshotPayload(payload)) {
return { turns: [], snapshot: null, complete: false, failure: { status: "network" } };
}
return {
turns: parsePersistedRectificationTurns(payload.turns),
snapshot: payload,
complete: true,
};
} catch {
return { turns: [], snapshot: null, complete: false, failure: { status: "network" } };
}
})();
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"
| "delivered";
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;
sessionOutcome?: string | null;
stopReason?: string | null;
/** Choice/targeted stem that cannot render a card. Delivery still takes this over repair. */
deadChoice?: boolean;
/**
* The latest assistant message still carries an unanswered tap question that
* cannot be answered (no live card, or its focus was superseded). A dead card
* and the collect-wait placeholder must never share a screen (BUG-917).
*/
unansweredDeadChoice?: boolean;
questionId?: string | null;
hasChoiceCard?: boolean;
busy: boolean;
readonly: 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) return "idle";
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible) return "idle";
if (interviewDeliveredGap(input)) return "delivered";
// BUG-917: an unanswerable tap question on the latest message is a repair
// exit, never 「再说一件带年月的事」.
if (input.unansweredDeadChoice) return "unavailable";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (input.offerAwaitingReader) return "idle";
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 persistedQuestionSurface(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
questionPersisted: boolean;
}>): "none" | "spoken" | "choice_card" {
if (!input.questionPersisted) return "none";
if (input.questionKind === "choice" && input.hasChoiceCard) return "choice_card";
return "spoken";
}
export function interviewChoiceCardUnavailable(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
questionId?: string | null;
}>): boolean {
// A choice question without a GET card is a dead tap target, not a
// collect-wait. The repair-exit path must take over.
if (input.questionKind === "choice" && !input.hasChoiceCard) return true;
const questionId = (input.questionId ?? "").replace(/:(?:next|next2|next3)$/, "");
if (!input.hasChoiceCard && /^varga\./.test(questionId)) return true;
if (
input.questionKind === "collect_spoken"
&& !input.hasChoiceCard
&& /^collect:targeted:[a-z_]+$/.test(questionId)
) {
return true;
}
return false;
}
let lastDeliveredDeadChoiceLog = "";
export function interviewDeliveredGap(input: Readonly<{
questionMissing: boolean;
sessionOutcome?: string | null;
stopReason?: string | null;
deadChoice?: boolean;
questionId?: string | null;
hasChoiceCard?: boolean;
}>): boolean {
const questionGone = input.questionMissing || input.deadChoice === true;
if (!questionGone) return false;
const delivered = sessionOutcomeAllowsDelivery(input.sessionOutcome)
|| input.stopReason === "tied_first"
|| input.stopReason === "user_uncertainty_too_high";
if (delivered && input.deadChoice === true) {
const payload = JSON.stringify({
event: "rectification_delivered_state_dead_choice",
session_outcome: input.sessionOutcome ?? null,
stop_reason: input.stopReason ?? null,
question_id: input.questionId ?? null,
has_choice_card: input.hasChoiceCard === true,
});
if (payload !== lastDeliveredDeadChoiceLog) {
lastDeliveredDeadChoiceLog = payload;
console.warn(payload);
}
}
return delivered;
}
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;
if (input.sessionOutcome === "collect_evidence" && !input.stopReason) return true;
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,
source?: string | null,
): Readonly<{
clock: string | null;
lines: readonly string[];
}> {
if (!declaredTime) {
return { clock: null, lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"] };
}
return {
clock: declaredTime,
lines: [boardDeclaredTimeLine(declaredTime, source), "回答几个问题后,这里会显示宫位随时间的变化。"],
};
}
export function birthTimeSourceFromSnapshot(payload: RectificationCaseSnapshotPayload | null | undefined): ProvenanceSource {
const raw = payload?.case?.birth_time_source;
return normalizeBirthTimeSource(typeof raw === "string" ? raw : null);
}
/** 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;
}