Merge pull request #76 from jesse-ux/staging
fix(rectification): expose agentic route and runtime trace
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<RectificationV4ApiResponse | null>(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 (
|
||||
<>
|
||||
<section className="conversation" aria-label="生时校正版本选择" aria-busy={switching}>
|
||||
<div className="message-list" aria-live="polite">
|
||||
<ChatMessageRow
|
||||
message={{
|
||||
role: "assistant",
|
||||
text: "检测到一段尚未结束的旧版生时校正。你可以继续保留进度,或结束旧版并使用新版 Agent 重新开始。",
|
||||
renderKey: "rectification-version-choice",
|
||||
state: "settled",
|
||||
}}
|
||||
/>
|
||||
{switchError && <p className="error-message" role="alert">{switchError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="composer-wrap">
|
||||
<div className="composer-suggestions" aria-label="选择生时校正版本">
|
||||
<button type="button" disabled={switching} onClick={() => setMode("v4")}>继续旧版校正</button>
|
||||
<button type="button" disabled={switching} onClick={() => void startAgentic()}>
|
||||
{switching ? "正在结束旧版校正…" : "结束旧版并使用新版 Agent"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "v4") {
|
||||
return <RectificationV4Panel {...props} />;
|
||||
return <RectificationV4Panel {...props} onUseAgentic={() => setMode("agentic")} />;
|
||||
}
|
||||
|
||||
return <AgenticRectificationChat {...props} />;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<section className="conversation" aria-label="生时校正对话" aria-busy={processing || controller.pending}>
|
||||
@@ -482,6 +489,7 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{controller.data?.runtimeTrace && <RectificationRuntimeDetails trace={controller.data.runtimeTrace} />}
|
||||
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
|
||||
<div ref={conversationEnd} />
|
||||
</div>
|
||||
@@ -489,7 +497,7 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
|
||||
|
||||
{showControls && (
|
||||
<div className="composer-wrap">
|
||||
{(canAcceptRange || canContinue || caseValue?.status === "paused") && (
|
||||
{(canAcceptRange || canContinue || caseValue?.status === "paused" || canUseAgentic) && (
|
||||
<div className="composer-suggestions" aria-label="生时校正操作">
|
||||
{canAcceptRange && (
|
||||
<button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}>
|
||||
@@ -506,6 +514,11 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
|
||||
继续校正
|
||||
</button>
|
||||
)}
|
||||
{canUseAgentic && (
|
||||
<button type="button" disabled={controller.pending} onClick={() => void startAgentic()}>
|
||||
结束旧版并使用新版 Agent
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<details className="evidence-audit-panel" open={trace.executionMode === "deterministic_fallback"}>
|
||||
<summary>运行信息 · {trace.executionMode}</summary>
|
||||
<div className="evidence-audit-table" role="table" aria-label="生时校正运行信息">
|
||||
{rows.map(([label, value, note]) => (
|
||||
<div className="evidence-audit-row" role="row" key={label}>
|
||||
<span role="cell">{label}</span>
|
||||
<b role="cell">{value}</b>
|
||||
<small role="cell">{note}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -115,10 +115,12 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
maxToolCalls?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
modelId?: string | null;
|
||||
generateDecision?: RectificationReasonerGenerator;
|
||||
}>): Promise<Readonly<{
|
||||
decision: RectificationDecision;
|
||||
mode: "agent" | "deterministic_fallback";
|
||||
modelId: string | null;
|
||||
fallbackReason: string | null;
|
||||
toolCalls: readonly ToolCallTrace[];
|
||||
inputTokenCount: number | null;
|
||||
@@ -128,8 +130,9 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
}>> {
|
||||
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,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createRectificationV4CaseService(
|
||||
const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion;
|
||||
|
||||
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,16 @@ export const rectificationAssistantResponseSchema = z.object({
|
||||
}).strict();
|
||||
export type RectificationAssistantResponse = z.infer<typeof rectificationAssistantResponseSchema>;
|
||||
|
||||
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<typeof rectificationRuntimeTraceSchema>;
|
||||
|
||||
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<typeof rectificationV4ApiResponseSchema>;
|
||||
|
||||
|
||||
@@ -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<RectificationRuntimeTrace | null> {
|
||||
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;
|
||||
|
||||
@@ -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<readonly RectificationV4Turn[]>;
|
||||
loadAnalysisMessages(userId: string, caseId: string): Promise<readonly RectificationAnalysisItem[]>;
|
||||
loadAssistantResponses(userId: string, caseId: string): Promise<readonly RectificationAssistantResponse[]>;
|
||||
loadLatestRuntimeTrace(userId: string, caseId: string): Promise<RectificationRuntimeTrace | null>;
|
||||
loadLatestValidatedDecision(userId: string, caseId: string): Promise<ValidatedDecision | null>;
|
||||
loadActionCase(userId: string, actionId: string): Promise<RectificationV4Case | null>;
|
||||
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
|
||||
|
||||
@@ -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<RectificationRuntimeTrace | null> {
|
||||
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<ValidatedDecision | null> {
|
||||
const { data, error } = await supabase.from("birth_time_rectification_agent_runs")
|
||||
.select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId)
|
||||
|
||||
@@ -63,6 +63,14 @@ function response(overrides: Record<string, unknown> = {}): 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, /<RectificationV4Panel \{\.\.\.props\} \/>/);
|
||||
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, /<RectificationV4Panel \{\.\.\.props\} onUseAgentic=/);
|
||||
assert.match(component, /controller\.abandon\(\)/);
|
||||
assert.match(component, /Runtime/);
|
||||
assert.match(component, /Deployment SHA/);
|
||||
assert.match(component, /fallbackCode/);
|
||||
assert.match(page, /\{!rectificationSurfaceOpen && \(\s*<div className=\{`conversation/);
|
||||
assert.match(page, /\{rectificationSurfaceOpen && \(\s*<ConversationalBirthTimeRectification/);
|
||||
assert.doesNotMatch(page, /is-rectification/);
|
||||
|
||||
@@ -155,7 +155,7 @@ test("V5 agent fallback persists the Director decision, Public Message and next
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2016年9月离家去外地上大学",
|
||||
answer: "2016年9月离家去外地上大学", modelId: "gpt-5.5",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
const worker = createRectificationV4Worker({
|
||||
@@ -169,6 +169,7 @@ test("V5 agent fallback persists the Director decision, Public Message and next
|
||||
const message = store.publicMessages.get(queued.job.id);
|
||||
assert.ok(event && run && message);
|
||||
assert.equal(run.deploymentMode, "v5_agent");
|
||||
assert.equal(run.modelId, "gpt-5.5");
|
||||
assert.equal(run.validatedDecision.mode, "deterministic_fallback");
|
||||
assert.equal(run.validatedDecision.decision.action, "ask_question");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity, null);
|
||||
@@ -180,6 +181,9 @@ test("V5 agent fallback persists the Director decision, Public Message and next
|
||||
assert.match(done?.case.currentQuestion?.prompt ?? "", /交叉核对/);
|
||||
assert.ok(message.question && done?.case.currentQuestion?.prompt.includes(message.question));
|
||||
assert.equal(done?.case.latestSnapshot, null);
|
||||
assert.equal(done?.runtimeTrace.modelId, "gpt-5.5");
|
||||
assert.equal(done?.runtimeTrace.executionMode, "deterministic_fallback");
|
||||
assert.equal(done?.runtimeTrace.fallbackCode, run.fallbackReason);
|
||||
}));
|
||||
|
||||
test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible projection", async () => withMode("v5_shadow", async () => {
|
||||
|
||||
Reference in New Issue
Block a user