Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/turn-decision.ts
T
2026-09-27 03:28:27 +08:00

387 lines
15 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 { collectionProgressFromReceipt } from "./evidence-model";
import type { V9CaseDossier } from "./tool-service";
import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
import { parseAgentChoiceCopy, serverOwnedChoiceCopy } from "./choice-card";
import { isCollectFocusSchema } from "./server-focus";
import { isTargetedCollectExistenceFocus, isYearStageQuestionId } from "./collection-question-pool";
import { rebuildTargetedCollectExistenceFrame } from "./method-followup";
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;
/**
* The only fields a candidate carries in the model-visible inference
* (BUG-1057). `score` is the inference probability; the model must still call
* it relative support, never a probability (Skill §9). Candidate ids, window
* positions and raw posteriors stay in the stored receipt.
*/
export const TURN_DECISION_CANDIDATE_FIELDS = ["time", "score", "status", "cluster_range"] as const;
/** When the payload is over budget, at most this many candidates survive (best first). */
export const TURN_DECISION_BUDGET_CANDIDATES = 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 roundScore(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value * 1000) / 1000 : null;
}
/**
* Model-visible slice of `compactInferenceProjection` (BUG-1057): candidates
* keep time / score / status / cluster_range only, and the last round names
* candidates by time instead of by id. Everything else is unchanged. The
* stored inference state and receipts are not touched.
*/
export function modelVisibleInference(
compact: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!compact) return null;
const rows = Array.isArray(compact.candidates) ? compact.candidates as Array<Record<string, unknown>> : [];
const timeById = new Map<string, string>();
for (const row of rows) {
if (typeof row.id === "string" && typeof row.time === "string") timeById.set(row.id, row.time);
}
const candidates = rows.map((row) => ({
time: row.time,
score: roundScore(row.probability),
status: row.status,
cluster_range: row.cluster_range,
}));
const round = compact.last_inference_round && typeof compact.last_inference_round === "object"
? compact.last_inference_round as Record<string, unknown>
: null;
const lastRound = round
? {
kind: round.kind,
entropy_before: round.entropy_before,
entropy_after: round.entropy_after,
eliminated_times: (Array.isArray(round.eliminated_ids) ? round.eliminated_ids : [])
.flatMap((id) => (typeof id === "string" && timeById.has(id) ? [timeById.get(id)!] : [])),
score_deltas: Object.fromEntries(
Object.entries(round.score_deltas && typeof round.score_deltas === "object"
? round.score_deltas as Record<string, unknown>
: {})
.flatMap(([id, delta]) => (timeById.has(id) ? [[timeById.get(id)!, roundScore(delta)]] : [])),
),
}
: null;
return { ...compact, candidates, last_inference_round: lastRound };
}
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 spoken = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
if (isYearStageQuestionId(focus.questionId) && spoken) {
return {
question_id: focus.questionId ?? null,
focus_id: focus.id ?? null,
probe_id: typeof schema?.probe_id === "string" ? schema.probe_id : null,
prompt: spoken,
kind: "collect_spoken",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
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: spoken.length >= 8 ? spoken : copy.prompt,
kind: "choice",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
const collectPrompt = spoken || "";
if (
focus.intent === "collect_method_evidence"
&& isTargetedCollectExistenceFocus(focus)
&& isCollectFocusSchema(schema)
&& collectPrompt
) {
const frame = rebuildTargetedCollectExistenceFrame({
questionId: focus.questionId,
domain: focus.targetDomain,
kindHint: typeof schema?.collect_kind === "string" ? schema.collect_kind : null,
prompt: collectPrompt,
});
const rebuilt = frame ? serverOwnedChoiceCopy(frame) : null;
if (rebuilt) {
return {
question_id: focus.questionId ?? null,
focus_id: focus.id ?? null,
probe_id: probeId,
prompt: rebuilt.prompt,
kind: "choice",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
}
if (isCollectFocusSchema(schema) && collectPrompt) {
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 (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 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 = modelVisibleInference(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,
// BUG-1057: the per-candidate list lives once, in inference.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,
},
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
};
return enforceTurnDecisionBudget(payload);
}
function withInferenceCandidates(
payload: Record<string, unknown>,
shape: (candidates: Array<Record<string, unknown>>) => { candidates: Array<Record<string, unknown>>; omitted?: number },
): Record<string, unknown> {
const inference = payload.inference && typeof payload.inference === "object" && !Array.isArray(payload.inference)
? payload.inference as Record<string, unknown>
: null;
if (!inference || !Array.isArray(inference.candidates)) return payload;
const shaped = shape(inference.candidates as Array<Record<string, unknown>>);
return {
...payload,
inference: {
...inference,
candidates: shaped.candidates,
...(shaped.omitted ? { candidates_omitted: shaped.omitted } : {}),
},
};
}
/**
* Over budget, candidate detail goes first and the conversation memory last
* (BUG-1057). The model gets no message history; `recent_turns` and
* `relevant_evidence_summary` are its only memory of the conversation.
* Order: drop each candidate's cluster_range → keep the best
* TURN_DECISION_BUDGET_CANDIDATES candidates (active before eliminated) →
* shorten turns/evidence → clear them (last resort, `truncated: true`).
*/
export function enforceTurnDecisionBudget(payload: Record<string, unknown>): Record<string, unknown> {
if (utf8Bytes(payload) <= TURN_DECISION_MAX_BYTES) return payload;
const noRanges = withInferenceCandidates(payload, (candidates) => ({
candidates: candidates.map((candidate) => {
const rest = { ...candidate };
delete rest.cluster_range;
return rest;
}),
}));
if (utf8Bytes(noRanges) <= TURN_DECISION_MAX_BYTES) return noRanges;
const topCandidates = withInferenceCandidates(noRanges, (candidates) => {
const ranked = [...candidates].sort((left, right) => {
const leftActive = left.status === "eliminated" ? 1 : 0;
const rightActive = right.status === "eliminated" ? 1 : 0;
if (leftActive !== rightActive) return leftActive - rightActive;
return (Number(right.score) || 0) - (Number(left.score) || 0);
});
const kept = ranked.slice(0, TURN_DECISION_BUDGET_CANDIDATES);
return { candidates: kept, omitted: candidates.length - kept.length };
});
if (utf8Bytes(topCandidates) <= TURN_DECISION_MAX_BYTES) return topCandidates;
const shrunk = {
...topCandidates,
recent_turns: Array.isArray(topCandidates.recent_turns)
? (topCandidates.recent_turns as unknown[]).slice(-4)
: [],
relevant_evidence_summary: Array.isArray(topCandidates.relevant_evidence_summary)
? (topCandidates.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);
}