Files
Jyotisha/frontend/src/lib/rectification-v4/case-service.ts
T
2026-07-30 00:41:48 +08:00

171 lines
6.9 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type {
CalculationSpec,
LifeEventRevision,
RectificationV4ApiResponse,
RectificationV4Case,
} from "./contracts.ts";
import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts";
import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts";
import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts";
import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { openingQuestion } from "./opening-question.ts";
import type { RectificationV4Store } from "./store.ts";
export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) {
const now = options.now ?? (() => new Date());
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
const [events, turns] = await Promise.all([
store.loadEvents(userId, caseValue.id),
store.loadTurns(userId, caseValue.id),
]);
return {
case: caseValue,
job: jobId ? await store.loadJob(userId, jobId) : null,
events: [...events],
turns: [...turns],
};
}
return {
async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) {
const timestamp = now().toISOString();
const deploymentMode = selectRectificationDeploymentMode(input.userId);
const caseValue: RectificationV4Case = {
id: randomUUID(),
userId: input.userId,
protocol: deploymentMode === "v4_legacy" ? rectificationV4Protocol : rectificationAgentV5Protocol,
version: 0,
status: "awaiting_answer",
phase: "collecting_evidence",
calculationSpec: input.calculationSpec,
calculationSpecHash: calculationSpecHash(input.calculationSpec),
evidenceSetHash: evidenceSetHash([]),
currentQuestion: openingQuestion(input.calculationSpec.candidateRange),
latestSnapshot: null,
orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null,
narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null,
skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION,
promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION,
algorithmVersion: rectificationV4AlgorithmVersion,
deploymentMode,
agentMode: "deterministic_fallback",
featureSnapshotId: null,
latestDiagnosticsId: null,
acceptedRange: null,
createdAt: timestamp,
updatedAt: timestamp,
};
return response(input.userId, await store.createCase({ case: caseValue, actionId: input.actionId }));
},
async loadCase(userId: string, caseId: string) {
const found = await store.loadCase(userId, caseId);
return found ? response(userId, found) : null;
},
async loadActive(userId: string) {
const found = await store.findActiveCase(userId);
return found ? response(userId, found) : null;
},
async loadJob(userId: string, jobId: string) {
return store.loadJob(userId, jobId);
},
async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string; readonly modelId?: string | null }) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion) return null;
const saved = await store.submitAnswer({
...input,
modelId: input.modelId ?? null,
question: current.currentQuestion,
jobId: randomUUID(),
turnId: randomUUID(),
now: now().toISOString(),
});
return response(input.userId, saved.case, saved.job.id);
},
async regenerateQuestion(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
}) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null;
const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId);
const opportunity = validated?.selectedOpportunity;
if (!opportunity) return null;
const [events, turns] = await Promise.all([
store.loadEvents(input.userId, input.caseId),
store.loadTurns(input.userId, input.caseId),
]);
const prompt = await regenerateQuestionRealization({
caseValue: current,
currentPrompt: current.currentQuestion.prompt,
latestAnswer: turns.at(-1)?.answer ?? "",
acceptedEvents: events,
opportunity,
});
const nextQuestion = {
...current.currentQuestion,
id: randomUUID(),
prompt,
};
return response(input.userId, await store.replaceCurrentQuestion({
...input,
question: nextQuestion,
now: now().toISOString(),
}));
},
async reviseEvent(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
readonly revision: LifeEventRevision;
}) {
const saved = await store.reviseEvent({ ...input, jobId: randomUUID(), now: now().toISOString() });
return response(input.userId, saved.case, saved.job.id);
},
async transition(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
readonly kind: "pause" | "resume" | "abandon";
}) {
const status = input.kind === "pause" ? "paused" : input.kind === "abandon" ? "abandoned" : "awaiting_answer";
const phase = input.kind === "abandon" ? "complete" : "collecting_evidence";
return response(input.userId, await store.transitionCase({ ...input, status, phase, now: now().toISOString() }));
},
async acceptRange(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
readonly startTime: string;
readonly endTime: string;
}) {
const current = await store.loadCase(input.userId, input.caseId);
const primary = current?.latestSnapshot?.clusters[0];
if (!current || !current.latestSnapshot?.canAcceptRange || !primary
|| primary.startTime !== input.startTime || primary.endTime !== input.endTime) return null;
return response(input.userId, await store.transitionCase({
...input,
status: "range_ready",
phase: "complete",
acceptedRange: { start: input.startTime, end: input.endTime },
now: now().toISOString(),
}));
},
};
}