Same behaviour; lint warnings back to the origin/staging count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
387 lines
15 KiB
TypeScript
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);
|
|
}
|