diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0f023b81..1edc894c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1960,3 +1960,18 @@ - 相关记录:BUG-107、BUG-108、BUG-109、BUG-110 - 复发自:BUG-109 - 修复版本:local / pending release + +## BUG-112 | 活动旧 Case 静默阻止新版 Agentic 生时校正 + +- 状态:resolved(staging pending deployment) +- 首次发现:2026-08-01 +- 最近更新:2026-08-01 +- 影响面:生时校正前端入口、V4 面板、V4 Reasoner 模型选择与运行信息 +- 用户现象:账户存在未结束的旧 Case 时,页面始终进入 `/api/rectification/v4/cases/*`;没有切换新版 Agent 的入口,模型选择器也不影响本轮 Reasoner,fallback 原因与部署版本不可见。 +- 触发条件:打开生时校正时 `loadActiveRectificationV4()` 返回活动 Case。 +- 根因:入口用 `existing ? "v4" : "agentic"` 静默分流;UI 未调用已有 `abandon()`;Reasoner 只读取 Case 固定模型;API 未返回最新 Agent Run 的安全运行摘要。 +- 修复:活动旧 Case 改为显式二选一;进入新版前先结束旧 Case;V4 面板增加同一切换操作;本轮 Turn 模型优先传给 Reasoner并记录实际模型;Case API 只公开最新运行的 mode、model、skill、deployment SHA 与 fallback code。 +- 验证:`frontend/tests/conversational-rectification-component.test.ts`、`frontend/tests/rectification-v4-service.test.ts`。 +- 防复发:入口合同禁止恢复静默 V4 分流;服务测试锁定本轮模型优先级与 runtime trace。 +- 相关记录:BUG-085、BUG-086、BUG-111 +- 修复版本:local / staging pending deployment diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 2f265bea..44025993 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { loadActiveRectificationV4 } from "../lib/rectification-v4/client.ts"; +import { loadActiveRectificationV4, transitionRectificationV4 } from "../lib/rectification-v4/client.ts"; +import type { RectificationV4ApiResponse } from "../lib/rectification-v4/contracts.ts"; import type { PublicLanguageModel } from "../lib/public-models.ts"; import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx"; import { ChatMessageRow } from "./chat-message-row.tsx"; @@ -24,19 +25,24 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{ /** * Birth-time rectification surface. * - * Resumes an existing v4 evidence case when one is still in progress (so users - * never lose a saved candidate range), and otherwise opens the agentic chat - * where the LLM drives the full Jyotish rectification methodology with the - * engine as its computation layer. + * Lets the user explicitly continue or end an existing v4 evidence case, and + * otherwise opens the agentic chat where the LLM drives the full Jyotish + * rectification methodology with the engine as its computation layer. */ export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) { - const [mode, setMode] = useState<"loading" | "v4" | "agentic">("loading"); + const [mode, setMode] = useState<"loading" | "choice" | "v4" | "agentic">("loading"); + const [existing, setExisting] = useState(null); + const [switching, setSwitching] = useState(false); + const [switchError, setSwitchError] = useState(""); useEffect(() => { let mounted = true; void (async () => { const existing = await loadActiveRectificationV4().catch(() => null); - if (mounted) setMode(existing ? "v4" : "agentic"); + if (mounted) { + setExisting(existing); + setMode(existing ? "choice" : "agentic"); + } })(); return () => { mounted = false; }; }, []); @@ -52,8 +58,48 @@ export function ConversationalBirthTimeRectification(props: ConversationalBirthT ); } + if (mode === "choice" && existing) { + const startAgentic = async () => { + setSwitching(true); + setSwitchError(""); + try { + await transitionRectificationV4(existing.case.id, existing.case.version, "abandon"); + setMode("agentic"); + } catch { + setSwitchError("无法结束旧版校正,请稍后再试。"); + } finally { + setSwitching(false); + } + }; + return ( + <> +
+
+ + {switchError &&

{switchError}

} +
+
+
+
+ + +
+
+ + ); + } + if (mode === "v4") { - return ; + return setMode("agentic")} />; } return ; diff --git a/frontend/src/components/rectification-v4-panel.tsx b/frontend/src/components/rectification-v4-panel.tsx index 41f19b0c..a3ef07d3 100644 --- a/frontend/src/components/rectification-v4-panel.tsx +++ b/frontend/src/components/rectification-v4-panel.tsx @@ -28,6 +28,7 @@ type RectificationV4PanelProps = Readonly<{ continuationPending?: boolean; onPendingChange?: (pending: boolean) => void; onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void; + onUseAgentic?: () => void; }>; type RectificationChatMessageView = ChatMessageView & Readonly<{ @@ -358,8 +359,9 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { && handoff?.status === "pending" && props.onContinueOriginalQuestion, ); + const canUseAgentic = Boolean(caseValue && caseValue.status !== "abandoned" && props.onUseAgentic); const showControls = Boolean(caseValue && caseValue.status !== "abandoned" && ( - canAnswer || canAcceptRange || canContinue || caseValue.status === "paused" + canAnswer || canAcceptRange || canContinue || caseValue.status === "paused" || canUseAgentic )); useEffect(() => { @@ -410,6 +412,11 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { }); } + async function startAgentic() { + const result = await controller.abandon(); + if (result) props.onUseAgentic?.(); + } + return ( <>
@@ -482,6 +489,7 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { ); })} + {controller.data?.runtimeTrace && } {controller.error &&

{controller.error}

}
@@ -489,7 +497,7 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { {showControls && (
- {(canAcceptRange || canContinue || caseValue?.status === "paused") && ( + {(canAcceptRange || canContinue || caseValue?.status === "paused" || canUseAgentic) && (
{canAcceptRange && ( )} + {canUseAgentic && ( + + )}
)} @@ -544,3 +557,28 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { ); } + + +function RectificationRuntimeDetails({ trace }: Readonly<{ trace: RectificationV4ApiResponse["runtimeTrace"] }>) { + const rows = [ + ["Runtime", trace.deploymentMode, "当前校正路由"], + ["Execution", trace.executionMode, trace.fallbackCode ?? "无 fallback code"], + ["Model", trace.modelId ?? "default / unavailable", "本轮实际选择"], + ["Skill", trace.skillVersion, "已注册版本"], + ["Deployment SHA", trace.deploymentSha ?? "unknown", "当前部署"], + ] as const; + return ( +
+ 运行信息 · {trace.executionMode} +
+ {rows.map(([label, value, note]) => ( +
+ {label} + {value} + {note} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index 7695da71..12c75f72 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -369,11 +369,13 @@ export async function regenerateDirectorQuestion(input: Readonly<{ return fallbackQuestion; } -export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) { +export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; modelId?: string | null; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) { const started = Date.now(); - const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); + const requestedModelId = input.modelId ?? input.caseValue.orchestrationModelId; + const model = (requestedModelId ? resolveLanguageModel(requestedModelId) : null) ?? defaultLanguageModel(); + const modelId = model?.id ?? requestedModelId; const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: `Direct the interview from the server-owned dossier and tool observations. Propose every explicit event in the latest answer, independently choose the current focus and wording from the full ledger, declined domains, candidate contrasts, and observations, and write the public reply plus at most one natural question. Do not follow a fixed domain rotation or treat examples, tests, or prior wording as a script. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. ${groundedPublicReplyRequirement} Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output.` }) : null; - const skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId: model?.id ?? null, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null }) : null; + const skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null }) : null; const generate = input.generatePlan ?? (async (prompt: string) => { if (!agent || !skillReady) throw new Error("director_model_unavailable"); await skillReady; @@ -476,7 +478,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect validated = validateRectificationTurnPlan({ plan: candidate, dossier, latestAnswer: input.latestAnswer, phase: input.phase }); } if (!validated.plan) throw new Error(`director_plan_rejected:${validated.issues.join(",")}`); - return { plan: validated.plan, dossier, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started }; + return { plan: validated.plan, dossier, mode: "agent" as const, modelId, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started }; } catch (error) { const fallbackPlan = fallback(dossier, input.latestAnswer); const validatedFallback = validateRectificationTurnPlan({ plan: fallbackPlan, dossier, latestAnswer: input.latestAnswer, phase: input.phase }); @@ -491,6 +493,6 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect const primaryReason = error instanceof Error ? error.message : "director_failed"; const fallbackValidationReason = validatedFallback.plan ? null : `fallback_rejected:${validatedFallback.issues.join(",")}`; const fallbackReason = [primaryReason, fallbackValidationReason].filter((value): value is string => Boolean(value)).join(";").slice(0, 240); - return { plan: safePlan, dossier, mode: "deterministic_fallback" as const, fallbackReason, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started }; + return { plan: safePlan, dossier, mode: "deterministic_fallback" as const, modelId, fallbackReason, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started }; } } diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index 271e48bb..3a9ed300 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -174,6 +174,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ }; await enterPhase("extracting_evidence"); const asOfDate = now.toISOString().slice(0, 10); + const selectedModelId = claimed.turn.modelId ?? claimed.case.orchestrationModelId; const provisionalDisposition = claimed.turn.questionTargetEventId ? "unresolved" as const : "not_applicable" as const; const provisionalDiagnostics = diagnosticsSummarySchema.parse({ id: randomUUID(), caseId: claimed.case.id, snapshotId: claimed.case.latestSnapshot?.id ?? randomUUID(), @@ -191,7 +192,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ currentTargetEventId: claimed.turn.questionTargetEventId, }); evidenceDirector = await runRectificationDirector({ - caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics, generatePlan: input.generateDirectorPlan, + caseValue: claimed.case, modelId: selectedModelId, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics, generatePlan: input.generateDirectorPlan, }); const serverReconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now }); reconciliation = claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent" && evidenceDirector.plan.evidenceProposals.length @@ -216,7 +217,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ || reconciliation.revisions.some((event) => event.scoreability === "pending_review" || event.scoreability === "unsupported") ); if (needsAssistance) { - const assisted = await extractEventWithModel({ rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, modelId: claimed.case.orchestrationModelId }); + const assisted = await extractEventWithModel({ rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, modelId: selectedModelId }); if (assisted) reconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, assistedEvidence: [assisted], now }); } const extracted = reconciliation.revisions; @@ -396,7 +397,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ }); await enterPhase("reasoning"); const directed = await runRectificationDirector({ - caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics, generatePlan: input.generateDirectorPlan, + caseValue: claimed.case, modelId: selectedModelId, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics, generatePlan: input.generateDirectorPlan, }); const plan = directed.plan; const action = plan.action; @@ -451,7 +452,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ const fallbackReason = [evidenceDirector?.fallbackReason, directed.fallbackReason].filter(Boolean).join(";").slice(0, 240) || null; const agentRun: AgentRun = { id: randomUUID(), caseId: claimed.case.id, jobId: claimed.job.id, caseVersion: claimed.case.version, - modelId: claimed.case.orchestrationModelId, skillVersion: claimed.case.skillVersion, promptVersion: claimed.case.promptVersion, + modelId: directed.modelId, skillVersion: claimed.case.skillVersion, promptVersion: claimed.case.promptVersion, deploymentMode: claimed.case.deploymentMode, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, decision, validatedDecision, toolCalls: [...directed.toolCalls], fallbackReason, inputTokenCount: totalInput || null, outputTokenCount: totalOutput || null, @@ -491,6 +492,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ await enterPhase("reasoning"); const reasoned = await runBoundedReasoner({ caseValue: claimed.case, + modelId: claimed.turn.modelId ?? claimed.case.orchestrationModelId, snapshot, diagnostics: safeDiagnostics, opportunities, @@ -520,7 +522,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ if (!validation.decision) { recordRectificationAgentTelemetry({ caseId: claimed.case.id, phase: "fallback", outcome: "rejected", - modelId: claimed.case.orchestrationModelId, toolName: null, + modelId: reasoned.modelId, toolName: null, decisionAction: rawDecision.action, durationMs: reasoned.latencyMs, errorCode: "policy_validator_rejected", deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, }); @@ -583,7 +585,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ caseId: claimed.case.id, jobId: claimed.job.id, caseVersion: claimed.case.version, - modelId: claimed.case.orchestrationModelId, + modelId: reasoned.modelId, skillVersion: claimed.case.skillVersion, promptVersion: claimed.case.promptVersion, deploymentMode: claimed.case.deploymentMode, diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts index dd839400..8a0d993a 100644 --- a/frontend/src/lib/rectification-agent/reasoner-agent.ts +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -115,10 +115,12 @@ export async function runBoundedReasoner(input: Readonly<{ maxToolCalls?: number; timeoutMs?: number; enabled?: boolean; + modelId?: string | null; generateDecision?: RectificationReasonerGenerator; }>): Promise> { const started = Date.now(); const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; - const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); - const modelId = model?.id ?? input.caseValue.orchestrationModelId; + const requestedModelId = input.modelId ?? input.caseValue.orchestrationModelId; + const model = (requestedModelId ? resolveLanguageModel(requestedModelId) : null) ?? defaultLanguageModel(); + const modelId = model?.id ?? requestedModelId; const toolCalls: ToolCallTrace[] = []; let inputTokenCount = 0; let outputTokenCount = 0; @@ -142,7 +145,7 @@ export async function runBoundedReasoner(input: Readonly<{ }); return { decision: deterministicDecision(input), mode: "deterministic_fallback" as const, - fallbackReason: reason, toolCalls: [...toolCalls], + modelId, fallbackReason: reason, toolCalls: [...toolCalls], inputTokenCount: usageObserved ? inputTokenCount : null, outputTokenCount: usageObserved ? outputTokenCount : null, latencyMs: Date.now() - started, @@ -260,7 +263,7 @@ export async function runBoundedReasoner(input: Readonly<{ const latencyMs = Date.now() - started; recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "succeeded", modelId, toolName: null, decisionAction: decision.action, durationMs: latencyMs, errorCode: null, deploymentSha }); return { - decision, mode: "agent", fallbackReason: null, toolCalls, + decision, mode: "agent", modelId, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokenCount : null, outputTokenCount: usageObserved ? outputTokenCount : null, latencyMs, diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 52e133bf..c9bb37b0 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -30,7 +30,7 @@ export function createRectificationV4CaseService( const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion; async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise { - const [events, turns, assistantResponses, job] = await Promise.all([ + const [events, turns, assistantResponses, job, latestRuntime] = await Promise.all([ store.loadEvents(userId, caseValue.id), store.loadTurns(userId, caseValue.id), caseValue.deploymentMode === "v5_agent" @@ -39,6 +39,7 @@ export function createRectificationV4CaseService( jobId ? store.loadJob(userId, jobId) : caseValue.status === "processing" ? store.loadActiveJob(userId, caseValue.id) : null, + store.loadLatestRuntimeTrace(userId, caseValue.id), ]); return { case: caseValue, @@ -47,6 +48,14 @@ export function createRectificationV4CaseService( turns: [...turns], analysis: assistantResponses.flatMap((item) => item.trace ? [{ sourceTurnId: item.sourceTurnId, trace: item.trace }] : []), assistantResponses: [...assistantResponses], + runtimeTrace: latestRuntime ?? { + deploymentMode: caseValue.deploymentMode, + executionMode: caseValue.agentMode, + modelId: caseValue.orchestrationModelId, + skillVersion: caseValue.skillVersion, + deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, + fallbackCode: null, + }, }; } diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index 16d38338..0e8c5b7a 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -364,6 +364,16 @@ export const rectificationAssistantResponseSchema = z.object({ }).strict(); export type RectificationAssistantResponse = z.infer; +export const rectificationRuntimeTraceSchema = z.object({ + deploymentMode: rectificationDeploymentModeSchema, + executionMode: z.enum(["agent", "deterministic_fallback"]), + modelId: z.string().trim().min(1).max(120).nullable(), + skillVersion: z.string().trim().min(1).max(120), + deploymentSha: z.string().trim().min(1).max(80).nullable(), + fallbackCode: z.string().trim().min(1).max(120).nullable(), +}).strict(); +export type RectificationRuntimeTrace = z.infer; + export const rectificationV4ApiResponseSchema = z.object({ case: rectificationV4CaseSchema, job: rectificationV4JobSchema.nullable(), @@ -371,6 +381,7 @@ export const rectificationV4ApiResponseSchema = z.object({ turns: z.array(rectificationV4TurnSchema), analysis: z.array(rectificationAnalysisItemSchema).optional(), assistantResponses: z.array(rectificationAssistantResponseSchema).optional(), + runtimeTrace: rectificationRuntimeTraceSchema, }).strict(); export type RectificationV4ApiResponse = z.infer; diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 8684a208..fbd77ab6 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -4,6 +4,7 @@ import type { PendingEvidence, RectificationV4Case, RectificationV4Job, + RectificationRuntimeTrace, } from "./contracts.ts"; import type { ClaimedRectificationV4Job, @@ -86,6 +87,20 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { return { sourceTurnId: job.turnId, message, trace: analysisTrace ?? null }; }); }, + async loadLatestRuntimeTrace(userId, caseId): Promise { + owned(userId, caseId); + const run = [...agentRuns.values()] + .filter((value) => value.caseId === caseId) + .sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0]; + return run ? { + deploymentMode: run.deploymentMode, + executionMode: run.validatedDecision.mode, + modelId: run.modelId, + skillVersion: run.skillVersion, + deploymentSha: run.deploymentSha, + fallbackCode: run.fallbackReason, + } : null; + }, async loadLatestValidatedDecision(userId, caseId) { const caseValue = cases.get(caseId); if (!caseValue || caseValue.userId !== userId) return null; diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 377d6ae7..3c1f9a2e 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -9,6 +9,7 @@ import type { RectificationV4Job, RectificationV4Phase, RectificationV4Question, + RectificationRuntimeTrace, RectificationV4Turn, } from "./contracts.ts"; export type { RectificationV4Turn } from "./contracts.ts"; @@ -61,6 +62,7 @@ export interface RectificationV4Store { loadTurns(userId: string, caseId: string): Promise; loadAnalysisMessages(userId: string, caseId: string): Promise; loadAssistantResponses(userId: string, caseId: string): Promise; + loadLatestRuntimeTrace(userId: string, caseId: string): Promise; loadLatestValidatedDecision(userId: string, caseId: string): Promise; loadActionCase(userId: string, actionId: string): Promise; createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 96e9c648..1cf12b31 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -6,6 +6,7 @@ import { pendingEvidenceSchema, rectificationAnalysisItemSchema, rectificationAssistantResponseSchema, + rectificationRuntimeTraceSchema, rectificationV4CaseSchema, rectificationV4JobSchema, rectificationV4TurnSchema, @@ -14,6 +15,7 @@ import { type PendingEvidence, type RectificationAnalysisItem, type RectificationAssistantResponse, + type RectificationRuntimeTrace, type RectificationV4Case, type RectificationV4Job, type RectificationV4Turn, @@ -285,6 +287,25 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re : []); }, loadAssistantResponses: loadAssistantResponsesByCase, + async loadLatestRuntimeTrace(userId, caseId): Promise { + const { data, error } = await supabase.from("birth_time_rectification_agent_runs") + .select("deployment_mode,model_id,skill_version,deployment_sha,fallback_reason,validated_decision_json") + .eq("case_id", caseId).eq("user_id", userId) + .order("case_version", { ascending: false }).order("created_at", { ascending: false }) + .limit(1).maybeSingle(); + if (error) throw storeError(error); + if (!data) return null; + const row = data as Row; + const validated = row.validated_decision_json as Row | null; + return rectificationRuntimeTraceSchema.parse({ + deploymentMode: row.deployment_mode as RectificationRuntimeTrace["deploymentMode"], + executionMode: validated?.mode === "agent" ? "agent" : "deterministic_fallback", + modelId: row.model_id ? String(row.model_id) : null, + skillVersion: String(row.skill_version), + deploymentSha: row.deployment_sha ? String(row.deployment_sha) : null, + fallbackCode: row.fallback_reason ? String(row.fallback_reason) : null, + }); + }, async loadLatestValidatedDecision(userId, caseId): Promise { const { data, error } = await supabase.from("birth_time_rectification_agent_runs") .select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId) diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index d639e52c..75a4c5f7 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -63,6 +63,14 @@ function response(overrides: Record = {}): RectificationV4ApiRe job: null, events: [], analysis: [], + runtimeTrace: { + deploymentMode: "v5_agent", + executionMode: "deterministic_fallback", + modelId: "gpt-5.5", + skillVersion: "birth-time-rectification-v5", + deploymentSha: "abc123", + fallbackCode: "reasoner_model_unavailable", + }, turns: [{ id: "00000000-0000-4000-8000-000000000903", caseId: id, @@ -129,7 +137,15 @@ test("v4 rectification reuses the ordinary session message list, composer, and m assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/); assert.match(component, /controller\.regenerate\(\)/); assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/); - assert.match(wrapper, //); + assert.doesNotMatch(wrapper, /existing \? "v4" : "agentic"/); + assert.match(wrapper, /继续旧版校正/); + assert.match(wrapper, /结束旧版并使用新版 Agent/); + assert.match(wrapper, /transitionRectificationV4\(existing\.case\.id, existing\.case\.version, "abandon"\)/); + assert.match(wrapper, / withMode("v5_shadow", async () => {