/** * Compact Case projection for a conversation turn. * * Free-chat Agent turns read this, not the full diagnostics dossier. Final * reports and admin replay use `full_diagnostics`. */ import { compactInferenceProjection, previousInferenceFromReceipt } from "./inference-adapter"; import { decideFromDossier } from "./decision-from-dossier"; import { evidenceLedgerFingerprint } from "./tool-service"; import type { V9CaseDossier } from "./tool-service"; import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract"; import { RECTIFICATION_SKILL_VERSION } from "./case-status"; import { parseAgentChoiceCopy } from "./choice-card"; export const TURN_DECISION_MAX_BYTES = 6 * 1024; export const TURN_DECISION_RECENT_TURNS = 6; export const TURN_DECISION_TURN_TEXT_LIMIT = 400; export const TURN_DECISION_EVIDENCE_LIMIT = 6; export type ReadCaseProjection = "turn_decision" | "full_diagnostics"; export const EXPLICIT_TERMINAL_OUTCOMES = [ "provisional_range", "provisional_range_user_stopped", "completed_with_range", "validated_range", "exact_minute_confirmed", "adopt_representative", "awaiting_confirmation", ] as const; export type CurrentQuestionProjection = Readonly<{ question_id: string | null; focus_id: string | null; probe_id: string | null; prompt: string | null; intent?: string; domain?: string | null; unrenderable?: true; reason?: string; }>; function utf8Bytes(value: unknown): number { return Buffer.byteLength(JSON.stringify(value), "utf8"); } function clipText(value: string | null | undefined, max: number): string | null { if (!value) return null; const text = value.trim(); if (!text) return null; return text.length <= max ? text : `${text.slice(0, max)}…`; } function looksLikeChoiceSchema(schema: Readonly> | null | undefined): boolean { if (!schema) return false; const choice = schema.choice; return Boolean( (choice && typeof choice === "object" && !Array.isArray(choice)) || typeof schema.probe_id === "string" || Array.isArray(schema.options) || typeof schema.option_a === "string" || typeof schema.optionA === "string", ); } export function projectCurrentQuestion( focus: { id?: string; questionId?: string; intent?: string; targetDomain?: string | null; expectedAnswerSchema?: Readonly> | null; } | null | undefined, ): CurrentQuestionProjection | null { if (!focus) return null; const schema = focus.expectedAnswerSchema; const copy = parseAgentChoiceCopy(schema); const probeId = typeof schema?.probe_id === "string" ? schema.probe_id : null; if (copy) { return { question_id: focus.questionId ?? null, focus_id: focus.id ?? null, probe_id: probeId, prompt: copy.prompt, intent: focus.intent, domain: focus.targetDomain ?? null, }; } const collectPrompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : ""; if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) { return { question_id: focus.questionId ?? null, focus_id: focus.id ?? null, probe_id: probeId, prompt: collectPrompt, intent: focus.intent, domain: focus.targetDomain ?? null, }; } if (!looksLikeChoiceSchema(schema)) return null; return { question_id: focus.questionId ?? null, focus_id: focus.id ?? null, probe_id: probeId, prompt: null, intent: focus.intent, domain: focus.targetDomain ?? null, unrenderable: true, reason: "invalid_choice_schema", }; } export function hasExplicitTerminalOutcome(outcome: string | null | undefined): boolean { return Boolean(outcome && (EXPLICIT_TERMINAL_OUTCOMES as readonly string[]).includes(outcome)); } export function projectTurnDecision( dossier: V9CaseDossier, extras: { nextAction?: Readonly> | null; currentQuestion?: (CurrentQuestionProjection & Record) | null; followupHint?: string | null; questionContract?: Readonly> | null; } = {}, ): Record { const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), }); const candidates = (dossier.latestResult?.candidates ?? []).slice(0, 6).map((item) => ({ time: item.time, relative_support: item.relativeSupport, rank: item.rank, })); const recentTurns = dossier.turns.slice(-TURN_DECISION_RECENT_TURNS).flatMap((turn) => { const text = clipText(turn.text, TURN_DECISION_TURN_TEXT_LIMIT); if (!text || (turn.status !== "completed" && !(turn.role === "user" && turn.status === "pending"))) { return []; } return [{ role: turn.role, text }]; }); const evidence = dossier.evidence.slice(-TURN_DECISION_EVIDENCE_LIMIT).map((item) => ({ domain: item.domain, kind: item.eventKind, status: item.status, date_precision: item.datePrecision, occurred_from: item.occurredFrom, summary: clipText(item.summary, 160), })); const focus = dossier.conversationSummary.activeFocus; const currentQuestion = extras.currentQuestion ?? projectCurrentQuestion(focus); const renderableQuestion = currentQuestion && currentQuestion.unrenderable !== true ? currentQuestion : null; const inferenceProjection = compactInferenceProjection(inference); const payload: Record = { projection: "turn_decision", case_id: dossier.case.caseId, case_revision: inference?.revision ?? 0, status: dossier.case.status, current_question: currentQuestion, current_probe: renderableQuestion ? inferenceProjection?.next_probe ?? null : null, candidate_summary: { representative_time: dossier.latestResult?.representativeTime ?? null, selection_allowed: decision.selectionAllowed, completion_status: decision.completionStatus, validated: decision.validated, candidates, entropy: inference?.entropy ?? null, }, inference: renderableQuestion || !inferenceProjection ? inferenceProjection : { ...inferenceProjection, next_probe: null }, next_action: extras.nextAction ?? { type: decision.nextAction, session_outcome: decision.sessionOutcome, completion_status: decision.completionStatus, validated: decision.validated, selection_allowed: decision.selectionAllowed, }, followup_hint: extras.followupHint ?? null, relevant_evidence_summary: evidence, recent_turns: recentTurns, evidence_counts: { confirmed: dossier.evidence.filter((item) => item.status === "confirmed").length, pending: dossier.evidence.filter((item) => ( item.status === "draft" || item.status === "pending_confirmation" )).length, }, question_contract: extras.questionContract ?? { version: QUESTION_CONTRACT_VERSION, git_sha: process.env.GITHUB_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.NEXT_PUBLIC_GIT_COMMIT ?? null, skill_version: RECTIFICATION_SKILL_VERSION, }, }; return enforceTurnDecisionBudget(payload); } export function enforceTurnDecisionBudget(payload: Record): Record { if (utf8Bytes(payload) <= TURN_DECISION_MAX_BYTES) return payload; const shrunk = { ...payload, recent_turns: Array.isArray(payload.recent_turns) ? (payload.recent_turns as unknown[]).slice(-4) : [], relevant_evidence_summary: Array.isArray(payload.relevant_evidence_summary) ? (payload.relevant_evidence_summary as unknown[]).slice(-3) : [], }; if (utf8Bytes(shrunk) <= TURN_DECISION_MAX_BYTES) return shrunk; return { ...shrunk, recent_turns: [], relevant_evidence_summary: [], truncated: true, }; } export function turnDecisionByteLength(value: unknown): number { return utf8Bytes(value); }