From ca7484666f0fb4c0c42fcd02b30812f839513ed9 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 11 Aug 2026 17:17:48 +0800 Subject: [PATCH] feat(rectification): stream agent execution and redesign entry routing Replace the Direct Agentic textStream relay with a durable V9 agent runtime: - agentic-rectification.ts: short boundary-only system prompt (no gate->scan ->score->diagnostics copy); pins skills/jyotish-birth-time-rectification; per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12, accept/confirm 6) with a hard ceiling and repeated-tool-call detection. - rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/ revise-evidence, compare-candidates, read-diagnostics, offer-candidates, accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and accepted!=confirmed semantics; confirm requires gate + grounded consent. - /api/rectification/agent: caseId/sessionId/requestId/action/message; exact Case<->Session binding verified server-side; client history never overrides the durable dossier; pending turn -> completed/failed/retryable; consumes result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider metadata/tool payloads/birth data/scores never forwarded); first-turn real skill.started/skill.loaded gate with one controlled retry; billing bound to rectification:case:{caseId}. - New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql: case dossier/compute, turn finalize, fingerprint-cached candidate persist, case-scoped accept, consent-gated confirm, guarded transitions, needs_rebaseline profile guard, run_phases receipt table, and the rectification_runtime_version feature flag (v9 default, legacy read-only). - Frontend: homepage/sidebar entry routing now uses the server Case open API (openRectificationFromHomepage/openRectificationSession/startNewRectification) with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven by entry-summary; chat restores from persisted turns, candidate cards from the Candidate Snapshot API, activity from real NDJSON + persisted receipts; direct durable candidate-accept endpoint for the UI cards. --- .../src/app/api/rectification/agent/route.ts | 509 ++++---- .../cases/[caseId]/candidates/accept/route.ts | 118 ++ .../api/rectification/cases/[caseId]/route.ts | 105 +- frontend/src/app/page.tsx | 230 +++- ...onversational-birth-time-rectification.tsx | 28 +- .../components/rectification-agentic-chat.tsx | 352 ++++-- .../lib/rectification-agentic/v9/agent-run.ts | 355 ++++++ .../rectification-agentic/v9/engine-client.ts | 248 ++++ .../v9/stream-mapping.ts | 117 ++ .../rectification-agentic/v9/tool-service.ts | 853 ++++++++++++++ frontend/src/lib/rectification-entry.ts | 144 +++ frontend/src/mastra/agentic-rectification.ts | 104 +- frontend/src/mastra/rectification-v9-tools.ts | 720 ++++++++++++ ...000_agentic_rectification_v9_agent_api.sql | 1042 +++++++++++++++++ 14 files changed, 4367 insertions(+), 558 deletions(-) create mode 100644 frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/agent-run.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/engine-client.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/stream-mapping.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/tool-service.ts create mode 100644 frontend/src/lib/rectification-entry.ts create mode 100644 frontend/src/mastra/rectification-v9-tools.ts create mode 100644 frontend/supabase/migrations/20260813010000_agentic_rectification_v9_agent_api.sql diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 41905dca..ab63a73f 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -1,91 +1,41 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { parseAgentReply } from "@/lib/agent-reply"; -import type { ChatMessage } from "@/lib/chat-message-view"; -import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification"; +import { getRectificationV9Agent, type RectificationAgentAction } from "@/mastra/agentic-rectification"; +import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service"; +import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run"; +import { safePublicEvent } from "@/lib/rectification-agentic/v9/stream-mapping"; +import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rectification-agentic/v9/case-status"; import { blocksPromptExtraction } from "@/lib/consult-safety"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; +import { loadRuntimeFeatureFlags } from "@/lib/feature-flags"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; -import { - AgenticRectificationProfileError, - acceptAgenticRectificationCandidate, - createAgenticRectificationContext, - loadAgenticRectificationProfile, - loadLatestAgenticRectificationResult, -} from "@/lib/rectification-agentic/session"; export const runtime = "nodejs"; export const maxDuration = 120; -const agenticRectificationConversationFields = { - requestId: z.string().uuid(), +const agentRequestSchema = z.object({ + caseId: z.string().uuid(), sessionId: z.string().uuid(), + requestId: z.string().uuid(), + action: z.enum(["opening", "message", "read_only"]), + message: z.string().trim().min(1).max(4000).optional(), modelId: z.string().trim().min(1).max(64).optional(), - name: z.string().trim().max(80).optional().default(""), - history: z - .array( - z.object({ - role: z.enum(["user", "assistant"]), - text: z.string().max(4000), - }), - ) - .max(30) - .default([]), -}; +}).strict(); -const agenticRectificationRequestSchema = z.discriminatedUnion("action", [ - z.object({ - ...agenticRectificationConversationFields, - action: z.literal("opening"), - }).strict(), - z.object({ - ...agenticRectificationConversationFields, - action: z.literal("message"), - message: z.string().trim().min(1).max(4000), - }).strict(), - z.object({ - action: z.literal("accept_candidate"), - sessionId: z.string().uuid(), - resultId: z.string().uuid(), - time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), - }).strict(), -]); - -const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event."; -const agenticRectificationMaxSteps = 8; - -function readPersistedMessages(value: unknown): ChatMessage[] { - if (!Array.isArray(value)) return []; - return value.flatMap((item): ChatMessage[] => { - if (!item || typeof item !== "object") return []; - const message = item as Partial; - if ((message.role !== "user" && message.role !== "assistant") || typeof message.text !== "string") return []; - return [{ - role: message.role, - text: message.text.slice(0, 100_000), - ...(Array.isArray(message.suggestions) - ? { suggestions: message.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string").slice(0, 3) } - : {}), - }]; - }); -} - -function currentTimeContext(now = new Date()) { - const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000) - .toISOString() - .replace("T", " ") - .slice(0, 19); - return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; +function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction { + if (action === "opening") return "opening"; + if (action === "read_only") return "read_only"; + return "evidence"; } async function rectificationBillingRequestId( - accounting: ReturnType, + accounting: Awaited>, userId: string, - sessionId: string, + caseId: string, ) { - const billingRequestPrefix = `rectification:${sessionId}`; + const billingRequestPrefix = `rectification:case:${caseId}`; const { data, error } = await accounting .from("usage_reservations") .select("request_id,status") @@ -102,43 +52,23 @@ async function rectificationBillingRequestId( : `${billingRequestPrefix}:retry:${reservations.length}`; } -export async function GET(request: Request) { - let supabase: Awaited>; - let accounting: ReturnType; - try { - supabase = await createServerSupabaseClient(); - accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); - } - const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - const sessionId = new URL(request.url).searchParams.get("sessionId") ?? ""; - if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "请求格式不正确" }, { status: 400 }); - const { data: session, error } = await supabase - .from("chat_sessions") - .select("id,session_type") - .eq("id", sessionId) - .eq("user_id", user.id) - .maybeSingle(); - if (error) return NextResponse.json({ error: "暂时无法读取生时校正会话" }, { status: 503 }); - if (!session || session.session_type !== "birth_time_rectification") return NextResponse.json({ error: "生时校正会话不存在" }, { status: 404 }); - try { - return NextResponse.json({ result: await loadLatestAgenticRectificationResult(accounting, user.id, sessionId) }); - } catch { - return NextResponse.json({ error: "暂时无法读取候选结果" }, { status: 503 }); - } -} - +/** + * POST /api/rectification/agent + * + * Case-ref API. The browser sends only caseId/sessionId/requestId/action/ + * message; the server verifies the exact Case↔Session binding, reads the + * durable dossier, streams the agent's fullStream and emits allowlisted NDJSON + * phases only. Client history can never override server history. + */ export async function POST(request: Request) { - let supabase: Awaited>; - let accounting: ReturnType; + let supabase; + let accounting; try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); } catch { return NextResponse.json( - { error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" }, + { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, { status: 503 }, ); } @@ -148,15 +78,10 @@ export async function POST(request: Request) { error: authError, } = await supabase.auth.getUser(); if (authError || !user) { - return NextResponse.json( - { error: "请先登录", message: "登录后才能开始生时校正。" }, - { status: 401 }, - ); + return NextResponse.json({ error: "请先登录" }, { status: 401 }); } - const parsed = agenticRectificationRequestSchema.safeParse( - await request.json().catch(() => null), - ); + const parsed = agentRequestSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( { error: "请求格式不正确", details: parsed.error.flatten() }, @@ -164,9 +89,8 @@ export async function POST(request: Request) { ); } - const promptSource = parsed.data.action === "accept_candidate" ? "" : [ - parsed.data.action === "message" ? parsed.data.message : "", - ...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text), + const promptSource = [ + parsed.data.action === "message" ? parsed.data.message ?? "" : "", ].join("\n"); if (blocksPromptExtraction(promptSource)) { return NextResponse.json( @@ -176,84 +100,82 @@ export async function POST(request: Request) { } const userId = user.id; - const requestTime = new Date(); + const { caseId, sessionId, requestId, action } = parsed.data; + + // Feature selector: the V9 runtime is DB-driven. When the flag is not + // published/enabled, no new runs are served (legacy stays read-only). + try { + const flags = await loadRuntimeFeatureFlags(["rectification_runtime_version"]); + const runtime = flags.get("rectification_runtime_version"); + if (!runtime?.enabled) { + return NextResponse.json( + { error: "生时校正服务暂未开放", code: "rectification_runtime_disabled" }, + { status: 503 }, + ); + } + } catch { + // Flag read failure defaults to the V9 runtime being unavailable. + return NextResponse.json( + { error: "生时校正服务暂未开放", code: "rectification_runtime_disabled" }, + { status: 503 }, + ); + } + + // Read the case projection to verify ownership + session binding + model. + const { data: caseRow, error: caseError } = await accounting.rpc( + "get_agentic_rectification_case", + { p_user_id: userId, p_case_id: caseId }, + ); + if (caseError) { + const message = caseError.message ?? ""; + if (message.includes("agentic_rectification_case_not_found")) { + return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 }); + } + return NextResponse.json({ error: "暂时无法读取校正记录", code: "rectification_service_failed" }, { status: 503 }); + } + const caseView = Array.isArray(caseRow) ? caseRow[0] : caseRow; + const boundSessionId = caseView && typeof caseView === "object" + ? (caseView as { session_id?: unknown }).session_id + : null; + if (typeof boundSessionId !== "string" || boundSessionId !== sessionId) { + return NextResponse.json( + { error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, + { status: 409 }, + ); + } + const caseStatus = caseView && typeof caseView === "object" + ? String((caseView as { status?: unknown }).status ?? "") + : ""; + const skillVersion = caseView && typeof caseView === "object" + ? String((caseView as { skill_version?: unknown }).skill_version ?? "") + : ""; + if (caseStatus === "confirmed" || caseStatus === "closed" + || caseStatus === "abandoned" || caseStatus === "superseded") { + return NextResponse.json( + { error: "该校正已结束,只能查看历史", code: "case_terminal" }, + { status: 409 }, + ); + } const { data: chatSession, error: chatSessionError } = await supabase .from("chat_sessions") - .select("id,messages,session_type,model_id,model_config_version") - .eq("id", parsed.data.sessionId) + .select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id") + .eq("id", sessionId) .eq("user_id", userId) .maybeSingle(); if (chatSessionError) { - return NextResponse.json( - { error: "暂时无法读取生时校正会话", message: "请稍后重试。" }, - { status: 503 }, - ); + return NextResponse.json({ error: "暂时无法读取会话", message: "请稍后重试。" }, { status: 503 }); } if (!chatSession || chatSession.session_type !== "birth_time_rectification") { - return NextResponse.json( - { error: "生时校正会话不存在", message: "请重新进入生时校正。" }, - { status: 404 }, - ); + return NextResponse.json({ error: "生时校正会话不存在", message: "请重新进入生时校正。" }, { status: 404 }); } - if (parsed.data.action === "accept_candidate") { - const accepted = await acceptAgenticRectificationCandidate( - accounting, - userId, - parsed.data.sessionId, - parsed.data.time, - parsed.data.resultId, - ); - if (!accepted.ok) { - return NextResponse.json( - { error: "暂时无法采用该候选时间", message: accepted.reason }, - { status: 409 }, - ); - } - return NextResponse.json(accepted); - } - const conversation = parsed.data; - const requestId = conversation.requestId; - const persistedMessages = readPersistedMessages(chatSession.messages); - if (conversation.action === "opening" && persistedMessages.length > 0) { + if (chatSession.agentic_rectification_case_id !== caseId) { return NextResponse.json( - { code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" }, + { error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 }, ); } - let profile; - try { - profile = await loadAgenticRectificationProfile(accounting, userId); - } catch (error) { - if (error instanceof AgenticRectificationProfileError) { - if (error.code === "profile_unavailable") { - return NextResponse.json( - { error: "暂时无法核对出生资料", message: "请稍后重试。" }, - { status: 503 }, - ); - } - return NextResponse.json( - { - code: "profile_incomplete", - error: "出生资料尚未完成", - message: "请先完成出生日期、出生时间线索和出生地点资料。", - }, - { status: 409 }, - ); - } - return NextResponse.json( - { error: "暂时无法核对出生资料", message: "请稍后重试。" }, - { status: 503 }, - ); - } - - if (!chatSession.model_id || (conversation.modelId && conversation.modelId !== chatSession.model_id)) { - return NextResponse.json( - { error: "会话模型已经变化", message: "请刷新生时校正会话后重试,本次不会扣除点数。" }, - { status: 409 }, - ); - } const selectedModel = await resolveSessionLanguageModel( chatSession.model_id, chatSession.model_config_version, @@ -265,160 +187,124 @@ export async function POST(request: Request) { ); } - let reserveResult; - let billingRequestId: string; - try { - billingRequestId = await rectificationBillingRequestId( - accounting, - userId, - conversation.sessionId, - ); - reserveResult = await authorizeUsage(accounting, { - userId, - requestId: billingRequestId, - featureKey: "rectification", - requestedModelId: selectedModel.id, - creditCost: selectedModel.creditCost, - }); - } catch (error) { - const reason = error instanceof Error ? error.name : "UnknownError"; - console.error(`[agentic-rectification] credit reserve failed request=${requestId} reason=${reason}`); - return NextResponse.json( - { error: "暂时无法确认咨询点数", message: "请稍后重试。" }, - { status: 503 }, - ); - } - if (!reserveResult.success) { - const insufficient = reserveResult.reason === "insufficient_credits"; - return NextResponse.json( - { - error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数", - message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.reason || "请稍后重试。", - }, - { status: insufficient ? 402 : 503 }, - ); - } - - const ctx = createAgenticRectificationContext(accounting, userId, profile, conversation.sessionId); - const agent = getAgenticRectificationAgent(selectedModel, ctx); + const requestTime = new Date(); + const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000) + .toISOString() + .replace("T", " ") + .slice(0, 19); + const timeContext = `服务端当前时间(权威):${requestTime.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; const encoder = new TextEncoder(); const body = new ReadableStream({ async start(controller) { - let emitted = false; - let raw = ""; - let settled = false; - const usageStartedAt = Date.now(); - const settle = async (complete: boolean, usage?: Promise<{ inputTokens?: number; outputTokens?: number }>) => { - if (settled) return true; - settled = true; - try { - let settlement; - if (complete) { - const resolved = await usage; - const inputTokens = Math.max(0, Math.trunc(resolved?.inputTokens ?? 0)); - const outputTokens = Math.max(0, Math.trunc(resolved?.outputTokens ?? 0)); - settlement = await completeUsage(accounting, userId, billingRequestId, { + let closed = false; + const send = (event: Record) => { + if (closed) return; + const safe = safePublicEvent(event); + if (!safe) return; + controller.enqueue(encoder.encode(`${JSON.stringify(safe)}\n`)); + }; + + const billing: V9RunBilling = { + async reserve() { + // opening / read-only turns are free; the first substantive run + // reserves once, and resume/retry reuse the same case-bound request. + if (action === "opening" || action === "read_only") { + return { success: true, status: 200 }; + } + try { + const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); + const result = await authorizeUsage(accounting, { + userId, + requestId: billingRequestId, + featureKey: "rectification", + requestedModelId: selectedModel.id, + creditCost: selectedModel.creditCost, + }); + if (result.success) return { success: true, status: 200 }; + return { + success: false, + reason: result.reason ?? "billing_denied", + status: result.reason === "insufficient_credits" ? 402 : 503, + }; + } catch (error) { + console.error(`[rectification-v9] reserve failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`); + return { success: false, reason: "billing_unavailable", status: 503 }; + } + }, + async complete(usage) { + try { + const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); + const settlement = await completeUsage(accounting, userId, billingRequestId, { eventKey: requestId, actualModelId: selectedModel.id, modelConfigVersion: selectedModel.configVersion, - inputTokens, - outputTokens, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, costMicrousd: Math.round(( - inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0) - + outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0) + usage.inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0) + + usage.outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0) ) / 1_000_000), - durationMs: Date.now() - usageStartedAt, + durationMs: usage.durationMs, }); - } else { - settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled"); + return settlement.success; + } catch (error) { + console.warn(`[rectification-v9] usage settle failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`); + return false; } - if (!settlement.success) throw new Error(settlement.error_code ?? "usage_settlement_failed"); - return true; - } catch (error) { - const reason = error instanceof Error ? error.message : "UnknownError"; - console.warn(`[agentic-rectification] usage settle failed request=${requestId} complete=${complete} reason=${reason}`); - return false; - } - }; - const send = (event: Record) => { - controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + }, + async release() { + try { + const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); + const settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled"); + return settlement.success; + } catch { + return false; + } + }, }; + try { - const result = await agent.stream( - [ - ...conversation.history.map((message) => message.role === "user" - ? { role: "user" as const, content: message.text } - : { role: "assistant" as const, content: message.text }), - { - role: "user", - content: [ - currentTimeContext(requestTime), - conversation.name ? `用户称呼:${conversation.name}` : "", - conversation.action === "opening" ? openingContext : conversation.message, - ].filter(Boolean).join("\n"), - }, - ], - { maxSteps: agenticRectificationMaxSteps }, - ); - for await (const chunk of result.textStream) { - if (/\S/.test(chunk)) emitted = true; - raw += chunk; - send({ type: "delta", text: chunk }); + const result = await runV9AgentTurn({ + userId, + caseId, + sessionId, + requestId, + action: actionToBudget(action), + message: action === "message" ? parsed.data.message ?? "" : null, + modelName: selectedModel.id, + skillName: RECTIFICATION_SKILL_NAME, + skillVersion: skillVersion || RECTIFICATION_SKILL_VERSION, + accounting: accounting as never, + billing, + emit: (event) => send(event), + signal: request.signal, + timeContext, + buildAgent: (turnId) => Promise.resolve( + getRectificationV9Agent(selectedModel, { + userId, + caseId, + turnId, + accounting: accounting as never, + }), + ), + }); + + if (!result.ok) { + send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" }); + } else { + send({ type: "done", emitted: true }); } - const reply = parseAgentReply(raw, "general"); - if (!emitted || !reply.text) { - console.warn(`[agentic-rectification] empty response request=${requestId}`); - send({ type: "error", message: "生时校正没有生成有效回复,本次不会扣除点数,请重新发送。" }); - await settle(false); - controller.close(); - return; - } - const requestHistory = conversation.history.map((message) => ({ - role: message.role, - text: message.text, - } satisfies ChatMessage)); - const baseMessages = requestHistory.length > persistedMessages.length - ? requestHistory - : persistedMessages; - const nextMessages: ChatMessage[] = [ - ...baseMessages, - ...(conversation.action === "message" - ? [{ role: "user" as const, text: conversation.message }] - : []), - { role: "assistant" as const, text: reply.text, suggestions: reply.suggestions }, - ].slice(-500); - const { data: savedSession, error: saveError } = await supabase - .from("chat_sessions") - .update({ messages: nextMessages, updated_at: new Date().toISOString() }) - .eq("id", conversation.sessionId) - .eq("user_id", userId) - .eq("session_type", "birth_time_rectification") - .select("id") - .maybeSingle(); - if (saveError || !savedSession) throw new Error("RectificationSessionPersistenceError"); - try { - const candidateResult = await loadLatestAgenticRectificationResult(accounting, userId, conversation.sessionId); - if (candidateResult) send({ type: "candidates", result: candidateResult }); - } catch { - console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`); - } - if (!await settle(true, result.totalUsage)) { - send({ type: "error", message: "生时校正回复已生成,但用量结算失败,请稍后重试。" }); - controller.close(); - return; - } - send({ type: "done", emitted: true }); - controller.close(); } catch (error) { - const reason = error instanceof Error ? error.name : "UnknownError"; - console.error(`[agentic-rectification] generation failed request=${requestId} reason=${reason}`); - try { + const code = error instanceof RectificationToolServiceError ? error.code : "run_failed"; + console.error(`[rectification-v9] run failed case=${caseId} code=${code}`); + if (code === "billing_denied" || code.includes("insufficient")) { + send({ type: "error", message: "咨询点数不足,请先兑换后再继续。" }); + } else { send({ type: "error", message: "生时校正暂时不可用,请稍后再试。" }); - } catch { - // controller may already be errored } - await settle(false); + } finally { + closed = true; try { controller.close(); } catch { @@ -434,6 +320,7 @@ export async function POST(request: Request) { "content-type": "application/x-ndjson; charset=utf-8", "x-accel-buffering": "no", "x-ayanam-request-id": requestId, + "x-rectification-case-id": caseId, }, }); } diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts new file mode 100644 index 00000000..7b070b83 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ caseId: string }> }; + +const acceptSchema = z.object({ + sessionId: z.string().uuid(), + resultId: z.string().uuid(), + candidateId: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + requestId: z.string().uuid(), +}).strict(); + +function errorResponse(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const known = [ + ["agentic_rectification_candidate_not_found", 404, "候选记录不存在或已过期"], + ["agentic_rectification_candidate_expired", 409, "候选结果已过期,请重新比较"], + ["agentic_rectification_candidate_selection_blocked", 409, "当前还不能采用候选"], + ["agentic_rectification_candidate_time_not_allowed", 409, "该时间不在当前候选内"], + ["agentic_rectification_candidate_already_selected", 409, "该时间已采用"], + ["agentic_rectification_candidate_superseded", 409, "已有更新的候选结果"], + ["agentic_rectification_candidate_profile_changed", 409, "出生资料已变化,候选已失效"], + ["agentic_rectification_case_terminal", 409, "该校正已结束"], + ["agentic_rectification_case_not_found", 404, "校正记录不存在或无权访问"], + ] as const; + for (const [code, status, text] of known) { + if (message.includes(code)) { + return NextResponse.json({ error: text, code: code.replace("agentic_rectification_", "") }, { status }); + } + } + return NextResponse.json( + { error: "暂时无法采用该候选时间", code: "candidate_accept_failed" }, + { status: 503 }, + ); +} + +/** + * POST /api/rectification/cases/[caseId]/candidates/accept + * + * Durable, case-scoped candidate adoption driven by the UI candidate card. + * accepted is never upgraded to confirmed; the confirmation gate lives in the + * agent confirm-birth-time tool / confirm RPC. + */ +export async function POST(request: Request, context: RouteContext) { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); + } + const parsed = acceptSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_accept_request" }, { status: 400 }); + } + + // Exact Case/Session binding before any write. + const { data: caseRow, error: caseError } = await accounting.rpc( + "get_agentic_rectification_case", + { p_user_id: user.id, p_case_id: caseId }, + ); + if (caseError) { + return errorResponse(new RectificationToolServiceError(caseError.message)); + } + const caseView = Array.isArray(caseRow) ? caseRow[0] : caseRow; + const boundSessionId = caseView && typeof caseView === "object" + ? (caseView as { session_id?: unknown }).session_id + : null; + if (typeof boundSessionId !== "string" || boundSessionId !== parsed.data.sessionId) { + return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 }); + } + + try { + const { data, error } = await accounting.rpc( + "accept_agentic_rectification_candidate_for_case", + { + p_user_id: user.id, + p_case_id: caseId, + p_result_id: parsed.data.resultId, + p_time: parsed.data.candidateId, + }, + ); + if (error) throw new RectificationToolServiceError(error.message); + const row = Array.isArray(data) ? data[0] : data; + if (!row || typeof row !== "object" || (row as { success?: unknown }).success !== true) { + return NextResponse.json({ error: "暂时无法采用该候选时间", code: "candidate_accept_rejected" }, { status: 409 }); + } + const result = row as Record; + return NextResponse.json({ + ok: true, + saved_time: result.saved_time, + status: result.status === "confirmed" ? "confirmed" : "accepted", + result_id: result.result_id, + case_status: result.case_status, + idempotent: result.idempotent === true, + }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 6a742331..a90193e1 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -3,22 +3,26 @@ import { z } from "zod"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { - RectificationCaseServiceError, - getRectificationCase, - mapRectificationRpcError, -} from "@/lib/rectification-agentic/v9/case-service"; + loadV9CaseDossier, + loadV9TurnReceipt, + receiptStatusFromTurn, + RectificationToolServiceError, + type V9CaseDossier, +} from "@/lib/rectification-agentic/v9/tool-service"; export const runtime = "nodejs"; type RouteContext = { params: Promise<{ caseId: string }> }; /** - * GET /api/rectification/cases/[caseId] + * GET /api/rectification/cases/[caseId]?sessionId=... * - * Sanitized case projection (never the baseline birth snapshot). Terminal - * cases are served read-only. + * Sanitized, durable refresh payload: case projection + persisted turns + + * evidence ledger + latest candidate snapshot + per-turn execution receipts. + * The browser restores real history from here; it never reconstructs history + * from candidate text or local sentinels. */ -export async function GET(_request: Request, context: RouteContext) { +export async function GET(request: Request, context: RouteContext) { let supabase; let accounting; try { @@ -37,26 +41,85 @@ export async function GET(_request: Request, context: RouteContext) { const { caseId } = await context.params; if (!z.string().uuid().safeParse(caseId).success) { - return NextResponse.json( - { error: "请求内容不正确", code: "invalid_case_id" }, - { status: 400 }, - ); + return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); } + const sessionId = new URL(request.url).searchParams.get("sessionId") ?? ""; try { - return NextResponse.json( - await getRectificationCase(accounting, user.id, caseId), + const dossier = await loadV9CaseDossier(accounting, user.id, caseId); + if (sessionId && dossier.case.sessionId !== sessionId) { + return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 }); + } + const receipts = await Promise.all( + dossier.turns.map(async (turn) => { + try { + return await loadV9TurnReceipt(accounting, user.id, caseId, turn.id); + } catch { + return null; + } + }), ); + return NextResponse.json(dossierResponse(dossier, receipts)); } catch (error) { - if (error instanceof RectificationCaseServiceError) { - const view = mapRectificationRpcError( - new Error(`agentic_rectification_${error.code}`), - ); - return NextResponse.json({ error: view.message, code: view.code }, { status: view.status }); + if (error instanceof RectificationToolServiceError) { + const message = error.message; + if (message.includes("agentic_rectification_case_not_found")) { + return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 }); + } } return NextResponse.json( - { error: "校正服务暂时不可用", code: "rectification_service_failed" }, - { status: 500 }, + { error: "暂时无法读取校正记录", code: "rectification_service_failed" }, + { status: 503 }, ); } } + +function dossierResponse( + dossier: V9CaseDossier, + receipts: Array>>, +) { + return { + case: { + case_id: dossier.case.caseId, + session_id: dossier.case.sessionId, + status: dossier.case.status, + skill_name: dossier.case.skillName, + skill_version: dossier.case.skillVersion, + candidate_range: dossier.case.candidateRange, + accepted_time: dossier.case.acceptedTime, + confirmed_time: dossier.case.confirmedTime, + completed_at: dossier.case.completedAt, + closed_reason: dossier.case.closedReason, + last_activity_at: dossier.case.lastActivityAt, + }, + turns: dossier.turns.map((turn) => ({ + id: turn.id, + role: turn.role, + text: turn.text, + status: turn.status, + created_at: turn.createdAt, + receipt: turnReceipt(turn.id, receipts), + })), + evidence: dossier.evidence, + latest_result: dossier.latestResult, + }; +} + +function turnReceipt( + turnId: string, + receipts: Array>>, +) { + const receipt = receipts.find((item) => item?.turnId === turnId) ?? null; + if (!receipt) return null; + return { + turn_id: receipt.turnId, + skill_name: receipt.skillName, + skill_version: receipt.skillVersion, + engine_version: receipt.engineVersion, + status: receiptStatusFromTurn(receipt.status), + phases: receipt.phases.map((phase) => phase.phase), + tools: receipt.tools, + started_at: receipt.startedAt, + completed_at: receipt.completedAt, + }; +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e9269100..c8036adc 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -15,7 +15,16 @@ import { } from "@/components/birth-time-assessment-overlay"; import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; -import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; +import { + entrySummaryFromResponse, + isTerminalRectificationStatus, + openRectificationRequestBody, + openResponseFromPayload, + rectificationEntryLabels, + resolveRectificationEntryAction, + type RectificationEntrySummary, +} from "@/lib/rectification-entry"; +import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ModelSelector } from "@/components/model-selector"; @@ -56,9 +65,7 @@ import { createBirthTimeConsultationConsentState, grantBirthTimeConsultationConsent, resolveBirthTimeConsultationRoute, - resolveRectificationCardAction, type BirthTimeConsultationConsentState, - type RectificationCardAction, } from "@/lib/birth-time-consultation-consent"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; @@ -244,11 +251,6 @@ const china = chinaLocations.country; const themes = defaultGuidedJyotishTopics; -const rectificationCardLabels = { - start: "开始生时校正", - resume: "继续上次校正", - revise: "再次校正", -} as const satisfies Record; const rectifyBeforeConsultationSuggestion = "先完成生时校正"; const accountDialogTitles = { @@ -1025,10 +1027,15 @@ export default function Home() { createBirthTimeConsultationConsentState, ); const [rectificationSessionId, setRectificationSessionId] = useState(null); + const [rectificationCaseId, setRectificationCaseId] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); const [rectificationError, setRectificationError] = useState(""); + const [rectificationReadonly, setRectificationReadonly] = useState(false); + const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false); + const [rectificationTurns, setRectificationTurns] = useState([]); + const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null); const [hydrated, setHydrated] = useState(false); const [profileSaving, setProfileSaving] = useState(false); const [creatingSession, setCreatingSession] = useState(false); @@ -1060,7 +1067,6 @@ export default function Home() { const consultationRecoveryCheck = useRef<() => void>(() => undefined); const consultationStatusMissingCount = useRef(0); const modelPersistence = useRef(new SessionModelPersistenceQueue()); - const rectificationPersistence = useRef(new SessionModelPersistenceQueue()); const modelSyncFailures = useRef(new Set()); const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); @@ -1098,13 +1104,15 @@ export default function Home() { || !modelCatalog; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; const accountId = account?.user.id; - const rectificationCardAction = resolveRectificationCardAction({ - hasRectificationSession: sessions.some( - (session) => session.sessionType === "birth_time_rectification", - ), - hasUsableBirthTime: account?.hasUsableBirthTime ?? false, - }); - const rectificationCardLabel = rectificationCardLabels[rectificationCardAction]; + const rectificationCardAction = resolveRectificationEntryAction( + rectificationEntrySummary ?? { + hasResumableCase: false, + hasTerminalCaseWithTime: false, + latestResumable: null, + latestTerminal: null, + }, + ); + const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction]; const onboardingFingerprint = onboardingProfileFingerprint(profile); useEffect(() => { @@ -1142,6 +1150,20 @@ export default function Home() { setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]")); }, [accountId, hydrated]); + useEffect(() => { + if (!hydrated || !accountId) return; + void (async () => { + try { + const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" }); + if (!response.ok) return; + const payload = await response.json().catch(() => null); + setRectificationEntrySummary(entrySummaryFromResponse(payload)); + } catch { + // The CTA falls back to the server-agnostic default labels. + } + })(); + }, [accountId, hydrated]); + useEffect(() => { if (!hydrated || !accountId) return; const prefix = `jyotisha-session-controls:${accountId}:`; @@ -1908,7 +1930,9 @@ export default function Home() { if (nextSession?.sessionType === "birth_time_rectification") { setRectificationError(""); if (nextSession.id !== rectificationSessionId) { - resumeRectificationSession.current(nextSession); + // The exact sessionId is passed to the server; the server resolves + // the exact Case and never switches to another rectification record. + void openRectificationSession(nextSession.id); } } } @@ -2299,11 +2323,7 @@ export default function Home() { const originalQuestion = [...activeSession.messages] .reverse() .find((message) => message.role === "user")?.text.trim(); - if (!originalQuestion) { - void openBirthTimeRectification(null, activeSession); - return; - } - void openBirthTimeRectification(originalQuestion, activeSession); + void openRectificationFromHomepage(originalQuestion ?? null); } function draftDailyStarlanguageQuestion() { @@ -2314,62 +2334,158 @@ export default function Home() { ); } - async function openBirthTimeRectification( - pendingConsultationQuestion: string | null = null, - sourceSessionOverride: ChatSession | null = null, + async function refreshRectificationEntrySummary() { + if (!account) return; + try { + const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" }); + if (!response.ok) return; + const payload = await response.json().catch(() => null); + setRectificationEntrySummary(entrySummaryFromResponse(payload)); + } catch { + // The CTA falls back to the server-agnostic default labels. + } + } + + async function refreshRectificationCase(caseId: string, sessionId: string) { + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, + { cache: "no-store" }, + ); + if (!response.ok) return; + const payload = await response.json().catch(() => null); + const turns = Array.isArray(payload?.turns) ? payload.turns : []; + setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({ + 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"), + receipt: turn?.receipt && typeof turn.receipt === "object" ? { + status: String((turn.receipt as { status?: unknown }).status ?? ""), + phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [], + tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [], + skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined, + skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined, + } : null, + }))); + } catch { + // History refresh is best-effort; the stream restores live turns. + } + } + + async function openRectificationCase( + intent: "homepage" | "session" | "new", + exactSessionId: string | null, + pendingConsultationQuestion: string | null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current - || rectificationMutationPending) return; + || rectificationMutationPending) return null; const missingStep = missingProfileStep(profile); if (missingStep) { setRectificationSessionId(null); + setRectificationCaseId(null); setRectificationPendingQuestion(null); setOnboardingStep(missingStep); setComposerNotice("请先完成出生资料,再开始生时校正。"); - return; + return null; } - const sourceSession = sourceSessionOverride ?? activeSession; - if (!sourceSession) return; - const existing = sourceSession.sessionType === "birth_time_rectification" - ? sourceSession - : sessions.find((session) => session.sessionType === "birth_time_rectification") ?? null; - const rectificationSession = existing ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); - const requestedQuestion = pendingConsultationQuestion; rectificationOpenInFlight.current = true; setRectificationLoading(true); setRectificationError(""); try { - if (!existing) { - await rectificationPersistence.current.enqueue( - rectificationSession.id, - () => persistSession(rectificationSession, "create"), - ); - setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]); + const response = await fetch("/api/rectification/cases/open", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const code = payload?.code; + if (code === "profile_incomplete" || code === "invalid_open_request") { + const step = missingProfileStep(profile); + if (step) { + setRectificationSessionId(null); + setRectificationCaseId(null); + setOnboardingStep(step); + setComposerNotice("请先完成出生资料,再开始生时校正。"); + } + } else if (code === "active_case_conflict") { + setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。"); + } else if (code === "case_session_not_found") { + setRectificationError("该校正会话不存在或已结束。"); + } else { + setRectificationError(payload?.error || "暂时无法打开生时校正。"); + } + return null; } - setRectificationPendingQuestion(requestedQuestion); + + const opened = openResponseFromPayload(payload); + if (!opened) { + setRectificationError("生时校正服务响应不正确,请稍后重试。"); + return null; + } + + // Merge the server-created session into the local list. The browser + // never generates a Case id; it only mirrors the returned binding. + const merged: ChatSession = { + id: opened.sessionId, + title: "生时校正", + theme: "general", + modelId: modelCatalog.defaultModelId ?? "", + messages: [], + updatedAt: timestamp(), + sessionType: "birth_time_rectification", + rectificationCaseId: opened.caseId, + }; + setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); + + setRectificationPendingQuestion(pendingConsultationQuestion); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); - setRectificationSessionId(rectificationSession.id); - activeSessionIdRef.current = rectificationSession.id; - setActiveSessionId(rectificationSession.id); + setRectificationSessionId(opened.sessionId); + setRectificationCaseId(opened.caseId); + setRectificationShouldStartOpening(opened.shouldStartOpening); + setRectificationReadonly( + opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status), + ); + setRectificationTurns([]); + activeSessionIdRef.current = opened.sessionId; + setActiveSessionId(opened.sessionId); + void refreshRectificationCase(opened.caseId, opened.sessionId); + void refreshRectificationEntrySummary(); + return opened; } catch { - setComposerNotice("生时校正会话暂时无法创建,请稍后重试。"); + setComposerNotice("生时校正会话暂时无法打开,请稍后重试。"); + return null; } finally { rectificationOpenInFlight.current = false; setRectificationLoading(false); } } + async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) { + await openRectificationCase("homepage", null, pendingConsultationQuestion); + } + + async function openRectificationSession(exactSessionId: string) { + await openRectificationCase("session", exactSessionId, null); + } + + async function startNewRectification() { + await openRectificationCase("new", null, null); + } + resumeRectificationSession.current = (session) => { - void openBirthTimeRectification(null, session); + void openRectificationSession(session.id); }; function handleRectificationProfileIncomplete() { setRectificationError("profile_incomplete"); setRectificationSessionId(null); + setRectificationCaseId(null); setRectificationPendingQuestion(null); const missingStep = missingProfileStep(profile); if (missingStep) { @@ -2678,7 +2794,7 @@ export default function Home() { } if (entrypoint === "birth_time_rectification") { - await openBirthTimeRectification(null); + await openRectificationFromHomepage(); return false; } @@ -3249,14 +3365,16 @@ export default function Home() { type="button" aria-label={rectificationCardLabel} disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending} - onClick={() => void openBirthTimeRectification()} + onClick={() => void openRectificationFromHomepage()} />
出生资料

生时校正

{rectificationCardAction === "resume" ? "你有一段未完成的校正记录,可以从上次的位置继续。" - : "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}

+ : rectificationCardAction === "restart" + ? "上一次校正已经完成,可以基于最新资料再次校正。" + : "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}

进度会自动保存;结果只作为候选范围,不会改写已填报出生时间。 @@ -3308,20 +3426,24 @@ export default function Home() {
)} - {rectificationSurfaceOpen && ( + {rectificationSurfaceOpen && rectificationCaseId && ( void selectSessionModel(modelId)} onMessagesChange={handleRectificationMessagesChange} onCompleted={() => void refreshAccount()} - pendingConsultationQuestion={rectificationPendingQuestion} onPendingChange={setRectificationMutationPending} onProfileIncomplete={handleRectificationProfileIncomplete} onSaved={() => void refreshAccount()} + pendingConsultationQuestion={rectificationPendingQuestion} + onRestart={() => void startNewRectification()} /> )} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 15adc2c9..e632c0a8 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -2,22 +2,40 @@ import type { PublicLanguageModel } from "../lib/public-models.ts"; import type { ChatMessage } from "../lib/chat-message-view.ts"; -import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx"; +import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx"; + +export type PersistedRectificationTurn = Readonly<{ + id: string; + role: "user" | "assistant"; + text: string | null; + status: string; + receipt?: Readonly<{ + status: string; + phases: readonly string[]; + tools: readonly string[]; + skill_name?: string; + skill_version?: string; + }> | null; +}>; export type ConversationalBirthTimeRectificationProps = Readonly<{ + caseId: string; sessionId: string; - initialMessages: readonly ChatMessage[]; + readonly: boolean; + shouldStartOpening: boolean; + initialTurns: readonly PersistedRectificationTurn[]; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; onMessagesChange?: (messages: ChatMessage[]) => void; onCompleted?: () => void; - pendingConsultationQuestion?: string | null; onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; - onSaved?: (time: string) => void; + onSaved?: (time: string, status: "accepted" | "confirmed") => void; + pendingConsultationQuestion?: string | null; + onRestart?: () => void; }>; export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) { - return ; + return ; } diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index afd052c1..613fdbc2 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -5,85 +5,142 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { parseAgentReply } from "@/lib/agent-reply"; import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; import { membershipHref } from "@/lib/membership"; +import { PUBLIC_RECTIFICATION_PHASES, type PublicRectificationPhase } from "@/lib/rectification-agentic/v9/public-receipt"; import type { PublicLanguageModel } from "@/lib/public-models"; import { ChatMessageRow } from "./chat-message-row"; import { ModelSelector } from "./model-selector"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; -type AgenticRectificationChatProps = Readonly<{ - sessionId: string; - initialMessages: readonly ChatMessage[]; - models: readonly PublicLanguageModel[]; - selectedModelId: string; - onSelectModel: (modelId: string) => void; - onMessagesChange?: (messages: ChatMessage[]) => void; - onCompleted?: () => void; - pendingConsultationQuestion?: string | null; - onPendingChange?: (pending: boolean) => void; - onProfileIncomplete?: () => void; - onSaved?: (time: string, status: "accepted" | "confirmed") => void; +type PersistedTurn = Readonly<{ + id: string; + role: "user" | "assistant"; + text: string | null; + status: string; + receipt?: Readonly<{ + status: string; + phases: readonly string[]; + tools: readonly string[]; + skill_name?: string; + skill_version?: string; + }> | null; }>; -type RenderMessage = ChatMessageView; - type CandidateResult = Readonly<{ resultId: string; candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[]; overallConfidence: "low" | "medium" | "high"; - marginPercent: number | null; selectionAllowed: boolean; confirmationAllowed: boolean; representativeTime: string | null; selectedTime: string | null; - selectionStatus: "accepted" | "confirmed" | null; + selectionKind: string | null; +}> | null; + +type RectificationAgenticChatProps = Readonly<{ + caseId: string; + sessionId: string; + readonly: boolean; + shouldStartOpening: boolean; + initialTurns: readonly PersistedTurn[]; + models: readonly PublicLanguageModel[]; + selectedModelId: string; + onSelectModel: (modelId: string) => void; + onMessagesChange?: (messages: ChatMessage[]) => void; + onCompleted?: () => void; + onPendingChange?: (pending: boolean) => void; + onProfileIncomplete?: () => void; + onSaved?: (time: string, status: "accepted" | "confirmed") => void; + pendingConsultationQuestion?: string | null; + onRestart?: () => void; }>; -const savedSentinel = //; +type RenderMessage = ChatMessageView & { + renderKey: string; + activity?: readonly string[]; + receiptStatus?: string; +}; -type AgenticRectificationRequest = Readonly< - | { action: "opening" } - | { action: "message"; message: string } ->; +const PHASE_LABELS: Readonly>> = { + "run.started": "开始本轮执行", + "skill.started": "正在加载专用方法", + "skill.loaded": "专用方法已加载", + "case.loaded": "已读取校正记录", + "evidence.proposed": "记录了一条事件草稿", + "evidence.confirmed": "事件已确认", + "candidates.comparing": "正在比较候选时间", + "candidates.updated": "候选已更新", + "diagnostics.completed": "稳健性诊断完成", + "candidate.accepted": "已采用候选时间", + "birth_time.confirmed": "校正时间已确认", + "run.completed": "本轮完成", + "run.failed": "本轮失败", +}; -export function AgenticRectificationChat(props: AgenticRectificationChatProps) { +function isPublicPhase(value: unknown): value is PublicRectificationPhase { + return typeof value === "string" && (PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes(value); +} + +function labelForPhase(phase: string): string { + return isPublicPhase(phase) ? (PHASE_LABELS[phase] ?? phase) : phase; +} + +function activityFromReceipt(receipt: PersistedTurn["receipt"]): string[] { + if (!receipt) return []; + const phases = (receipt.phases ?? []).filter(isPublicPhase); + return phases.map(labelForPhase).slice(0, 12); +} + +export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { + caseId, sessionId, - initialMessages, + readonly, + shouldStartOpening, + initialTurns, models, selectedModelId, onSelectModel, onMessagesChange, onCompleted, - pendingConsultationQuestion, onPendingChange, onProfileIncomplete, onSaved, + pendingConsultationQuestion, + onRestart, } = props; - const pendingQuestion = pendingConsultationQuestion?.trim(); - const [messages, setMessages] = useState(() => [ - ...initialMessages.map((message, index) => ({ - ...message, - renderKey: `agentic-message-${index}`, - state: "settled" as const, - })), - ...(initialMessages.length === 0 && pendingQuestion ? [{ - role: "assistant" as const, - text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}”`, - renderKey: "agentic-pending-consultation", - state: "settled" as const, - }] : []), - ]); + + const [messages, setMessages] = useState(() => + initialTurns.flatMap((turn, index): RenderMessage[] => { + const key = `persisted-${turn.id}-${index}`; + if (turn.role === "assistant") { + return [{ + role: "assistant", + text: turn.text ?? "", + renderKey: key, + state: turn.status === "completed" ? "settled" : "thinking", + activity: activityFromReceipt(turn.receipt), + receiptStatus: turn.receipt?.status, + }]; + } + return [{ + role: "user", + text: turn.text ?? "", + renderKey: key, + state: "settled", + }]; + }), + ); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [savedTime, setSavedTime] = useState(null); const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); - const [candidateResult, setCandidateResult] = useState(null); + const [candidateResult, setCandidateResult] = useState(null); const [acceptingTime, setAcceptingTime] = useState(null); const [suggestions, setSuggestions] = useState([]); - const composer = useRef(null); const conversation = useRef(null); + const composer = useRef(null); const keyCounter = useRef(0); const openingStarted = useRef(false); @@ -102,49 +159,77 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { }); }, [busy, error, messages, savedTime]); - const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => { - const trimmed = request.action === "message" ? request.message.trim() : ""; - if ((request.action === "message" && !trimmed) || busy) return; + // Candidate snapshot comes from the persisted Candidate Snapshot API, never + // from parsing agent text or hidden sentinels. + const loadCandidate = useCallback(async (): Promise => { + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, + { cache: "no-store" }, + ); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + const latest = payload?.latest_result; + if (!latest || typeof latest.result_id !== "string") return null; + return { + resultId: latest.result_id, + candidates: Array.isArray(latest.candidates) ? latest.candidates : [], + overallConfidence: latest.overall_confidence === "high" || latest.overall_confidence === "medium" ? latest.overall_confidence : "low", + selectionAllowed: latest.selection_allowed === true, + confirmationAllowed: latest.confirmation_allowed === true, + representativeTime: typeof latest.representative_time === "string" ? latest.representative_time : null, + selectedTime: typeof latest.selected_time === "string" ? latest.selected_time : null, + selectionKind: typeof latest.selection_kind === "string" ? latest.selection_kind : null, + }; + } catch { + return null; + } + }, [caseId, sessionId]); + + useEffect(() => { + let active = true; + void loadCandidate().then((result) => { + if (active) setCandidateResult(result); + }); + return () => { active = false; }; + }, [loadCandidate]); + + const send = useCallback(async (action: "opening" | "message", messageText: string) => { + const trimmed = action === "message" ? messageText.trim() : ""; + if ((action === "message" && !trimmed) || busy || readonly) return; setError(""); setSuggestions([]); setPending(true); keyCounter.current += 1; const requestId = globalThis.crypto.randomUUID(); - const settledMessages = messages - .filter((message) => message.state === "settled") - .map((message) => ({ - role: message.role, - text: message.text, - ...(message.suggestions ? { suggestions: message.suggestions } : {}), - })); - const history = settledMessages.map((message) => ({ role: message.role, text: message.text })); const turnKey = keyCounter.current; - const userRenderKey = `agentic-user-${turnKey}`; - const assistantRenderKey = `agentic-assistant-${turnKey}`; + const userRenderKey = `v9-user-${turnKey}`; + const assistantRenderKey = `v9-assistant-${turnKey}`; setMessages((current) => [ ...current, - ...(showUserMessage && request.action === "message" + ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []), - { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" }, + { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: [] }, ]); setDraft(""); let raw = ""; - let streamedSavedStatus: "accepted" | "confirmed" | null = null; + let liveActivity: string[] = []; + const activitySet = new Set(); try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - requestId, + caseId, sessionId, + requestId, + action, modelId: selectedModelId, - history, - action: request.action, - ...(request.action === "message" ? { message: trimmed } : {}), + ...(action === "message" ? { message: trimmed } : {}), }), }); if (!response.ok) { @@ -164,7 +249,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { return; } if (!response.body) { - setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); + setMessages((current) => current.filter((item) => item.renderKey !== assistantRenderKey)); setError("服务暂时不可用,请稍后再试。"); return; } @@ -182,33 +267,35 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; - let event: { type: string; text?: string; message?: string; result?: CandidateResult }; + let event: { type?: unknown; text?: unknown; message?: unknown }; try { - event = JSON.parse(line) as { type: string; text?: string; message?: string; result?: CandidateResult }; + event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown }; } catch { continue; } - if (event.type === "delta" && typeof event.text === "string") { + if (typeof event.type !== "string") continue; + if (event.type === "answer.delta" && typeof event.text === "string") { raw += event.text; const parsed = parseAgentReply(raw, "general"); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: parsed.text, state: "streaming" } : message)); setSuggestions(parsed.suggestions); - const saved = raw.match(savedSentinel); - if (saved) setSavedTime(saved[1]); - } else if (event.type === "candidates" && event.result) { - setCandidateResult(event.result); - if (event.result.selectedTime && event.result.selectionStatus) { - setSavedTime(event.result.selectedTime); - streamedSavedStatus = event.result.selectionStatus; - setSavedStatus(event.result.selectionStatus); - } + } else if (event.type === "run.failed") { + streamFailed = true; } else if (event.type === "error") { streamFailed = true; - setError(event.message || "生时校正暂时不可用,请稍后再试。"); - } else if (event.type === "done") { + setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。"); + } else if (event.type === "run.completed") { completed = true; + } else if (isPublicPhase(event.type)) { + if (!activitySet.has(event.type)) { + activitySet.add(event.type); + liveActivity = [...liveActivity, labelForPhase(event.type)]; + setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey + ? { ...message, activity: liveActivity } + : message)); + } } } } @@ -217,22 +304,19 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => succeeded ? current.map((message) => message.renderKey === assistantRenderKey - ? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled" } + ? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled", activity: liveActivity } : message) : current.filter((message) => message.renderKey !== assistantRenderKey)); setSuggestions(succeeded ? parsed.suggestions : []); if (succeeded) { onMessagesChange?.([ - ...settledMessages, - ...(request.action === "message" ? [{ role: "user" as const, text: trimmed }] : []), + ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), { role: "assistant", text: parsed.text, suggestions: parsed.suggestions }, ]); onCompleted?.(); - } - const saved = raw.match(savedSentinel); - if (saved) { - setSavedTime(saved[1]); - onSaved?.(saved[1], streamedSavedStatus ?? savedStatus ?? "accepted"); + await loadCandidate().then((result) => { + if (result) setCandidateResult(result); + }); } } catch { setError("生时校正暂时不可用,请稍后再试。"); @@ -240,72 +324,78 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { } finally { setPending(false); } - }, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, savedStatus, selectedModelId, sessionId, setPending]); + }, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]); useEffect(() => { - let active = true; - void fetch(`/api/rectification/agent?sessionId=${encodeURIComponent(sessionId)}`) - .then((response) => response.ok ? response.json() : null) - .then((payload) => { - const result = payload?.result as CandidateResult | null | undefined; - if (!active || !result) return; - setCandidateResult(result); - if (result.selectedTime && result.selectionStatus) { - setSavedTime(result.selectedTime); - setSavedStatus(result.selectionStatus); - } - }) - .catch(() => undefined); - return () => { active = false; }; - }, [sessionId]); + if (readonly || openingStarted.current || !shouldStartOpening) return; + openingStarted.current = true; + void send("opening", ""); + }, [readonly, send, shouldStartOpening]); const acceptCandidate = useCallback(async (time: string) => { - if (!candidateResult || acceptingTime) return; + if (!candidateResult || acceptingTime || readonly) return; setError(""); setAcceptingTime(time); try { - const response = await fetch("/api/rectification/agent", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - action: "accept_candidate", - sessionId, - resultId: candidateResult.resultId, - time, - }), - }); + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionId, + resultId: candidateResult.resultId, + candidateId: time, + requestId: globalThis.crypto.randomUUID(), + }), + }, + ); const payload = await response.json().catch(() => null); - if (!response.ok || payload?.ok !== true) throw new Error(payload?.message || payload?.error || "暂时无法采用该候选时间"); + if (!response.ok || payload?.ok !== true) { + throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间"); + } const status = payload.status === "confirmed" ? "confirmed" : "accepted"; - setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionStatus: status } : current); + setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: status === "confirmed" ? "engine_confirmed" : "user_accepted" } : current); setSavedTime(payload.saved_time); setSavedStatus(status); onSaved?.(payload.saved_time, status); + onCompleted?.(); } catch (caught) { setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { setAcceptingTime(null); } - }, [acceptingTime, candidateResult, onSaved, sessionId]); - - useEffect(() => { - if (initialMessages.length > 0 || openingStarted.current) return; - openingStarted.current = true; - void send({ action: "opening" }, false); - }, [initialMessages.length, send]); + }, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]); async function submit(event: React.FormEvent) { event.preventDefault(); - await send({ action: "message", message: draft }); + await send("message", draft); } - const canSend = !busy; + const canSend = !busy && !readonly; return ( <>
- {messages.map((message) => )} + {pendingConsultationQuestion?.trim() && ( +

+ 先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}” +

+ )} + {messages.map((message) => ( +
+ + {message.activity && message.activity.length > 0 && ( +
+ 本轮做了什么 +
    + {message.activity.map((step) =>
  1. {step}
  2. )} +
+
+ )} +
+ ))} {candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
@@ -322,7 +412,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { type="button" className={`rectification-candidate${selected ? " is-selected" : ""}`} key={`${candidateResult.resultId}-${candidate.time}`} - disabled={selected || Boolean(acceptingTime)} + disabled={selected || Boolean(acceptingTime) || readonly} onClick={() => void acceptCandidate(candidate.time)} > @@ -346,6 +436,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {

)} {error &&

{error}

} + {readonly && ( +
+

该校正已结束,只能查看历史。

+ +
+ )}
@@ -353,7 +449,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) { {suggestions.length > 0 && !busy && (
{suggestions.map((question) => ( - + ))}
)} @@ -361,10 +457,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {