238 lines
8.3 KiB
TypeScript
238 lines
8.3 KiB
TypeScript
/**
|
|
* 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 CurrentQuestionKind = "choice" | "collect_spoken";
|
|
|
|
export type CurrentQuestionProjection = Readonly<{
|
|
question_id: string | null;
|
|
focus_id: string | null;
|
|
probe_id: string | null;
|
|
prompt: string | null;
|
|
kind?: CurrentQuestionKind;
|
|
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<Record<string, unknown>> | 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<Record<string, unknown>> | 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,
|
|
kind: "choice",
|
|
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,
|
|
kind: "collect_spoken",
|
|
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,
|
|
kind: "choice",
|
|
intent: focus.intent,
|
|
domain: focus.targetDomain ?? null,
|
|
unrenderable: true,
|
|
reason: "invalid_choice_schema",
|
|
};
|
|
}
|
|
|
|
function isRenderableChoiceQuestion(
|
|
question: CurrentQuestionProjection | null | undefined,
|
|
): question is CurrentQuestionProjection {
|
|
return Boolean(question && question.kind === "choice" && question.unrenderable !== true);
|
|
}
|
|
|
|
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<Record<string, unknown>> | null;
|
|
currentQuestion?: (CurrentQuestionProjection & Record<string, unknown>) | null;
|
|
followupHint?: string | null;
|
|
questionContract?: Readonly<Record<string, unknown>> | null;
|
|
} = {},
|
|
): Record<string, unknown> {
|
|
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 = isRenderableChoiceQuestion(currentQuestion)
|
|
? currentQuestion
|
|
: null;
|
|
const inferenceProjection = compactInferenceProjection(inference);
|
|
const payload: Record<string, unknown> = {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
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);
|
|
}
|