Project Files
src / session.ts
import { randomUUID } from "node:crypto";
import { streamComplete, type ChatMessage } from "./llm";
import { buildCorrectivePrompt, verify, type FaultyQuote } from "./verifier";
import {
validateSkeleton,
buildSkeletonCorrective,
skeletonViolationSummary,
type SkeletonViolation,
} from "./skeleton-validator";
import type { Citation } from "./corpus";
import {
appendTurn,
loadSession as loadFromDisk,
sessionExists,
writeMeta,
type SessionMeta,
} from "./storage";
export type SessionConfig = {
host: string;
model: string;
temperature: number;
maxRetries: number;
};
export type SessionState = {
id: string;
meta: SessionMeta;
messages: ChatMessage[];
corpus: Citation[];
cfg: SessionConfig;
// Ordre des 5 cartes décidé à la création. Source de vérité absolue
// pour quelle carte jouer à quel tour. Synchronisé avec meta.cardOrder
// (persisté disque).
cardOrder: string[];
};
export type TurnResult = {
content: string;
attempts: Array<{ attempt: number; realFaulty: number; stateOk: boolean; skeletonOk: boolean }>;
finalFaulty: FaultyQuote[];
finalStateViolation: StateViolation | null;
finalSkeletonViolations: SkeletonViolation[];
matched: number;
totalQuotes: number;
};
const sessions = new Map<string, SessionState>();
// Les 5 cartes pédagogiques. CARDS est l'ordre dialectique canonique
// (descend du problème vers la synthèse). On l'utilise comme base pour
// construire l'ordre effectif d'une session via pickCardOrder().
const CARDS = ["problématiser", "conceptualiser", "illustrer", "objecter", "synthétiser"] as const;
function stripAccents(s: string): string {
return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
}
// Tire l'ordre des 5 cartes pour une nouvelle session :
// - Démarrage random parmi {problématiser, conceptualiser} (les deux
// ouvertures naturelles d'une dissertation).
// - Le reste suit l'ordre dialectique canonique (illustrer → objecter →
// synthétiser), en remettant celui qu'on n'a pas tiré en position 2.
// Garanties : synthétiser toujours en 5/5 (climax), problématiser et
// conceptualiser toujours dans les 2 premiers tours.
export function pickCardOrder(): string[] {
const startsWithProblem = Math.random() < 0.5;
if (startsWithProblem) {
return ["problématiser", "conceptualiser", "illustrer", "objecter", "synthétiser"];
}
return ["conceptualiser", "problématiser", "illustrer", "objecter", "synthétiser"];
}
// Synthétise un cardOrder pour une session pré-existante qui n'en avait
// pas (rétro-compat). On préserve les cartes déjà jouées en tête, puis
// on complète avec les restantes dans l'ordre dialectique.
function synthesizeCardOrder(messages: ChatMessage[]): string[] {
const consumed: string[] = [];
const seen = new Set<string>();
for (const m of messages) {
if (m.role !== "assistant") continue;
for (const match of m.content.matchAll(/Op[ée]ration\s+(\d)\s*\/\s*5\s*[—-]\s*([a-zéèêàâîôûç]+)/gi)) {
const cardRaw = stripAccents(match[2]);
const card = CARDS.find((c) => stripAccents(c) === cardRaw);
if (card && !seen.has(card)) { seen.add(card); consumed.push(card); }
}
}
const remaining = CARDS.filter((c) => !seen.has(c));
return [...consumed, ...remaining];
}
// Compte les tours assistant déjà joués (= cartes consommées).
function countAssistantTurns(messages: ChatMessage[]): number {
return messages.filter((m) => m.role === "assistant").length;
}
// Dérive l'état d'une session à partir de l'ordre des cartes fixé à la
// création + le compteur de tours assistant. Pas de parsing d'italiques :
// le serveur EST l'autorité.
function computeSessionState(messages: ChatMessage[], cardOrder: string[]): {
consumed: string[];
remaining: string[];
nextNumber: number;
nextCard: string | null;
} {
const turns = countAssistantTurns(messages);
const consumed = cardOrder.slice(0, turns);
const remaining = cardOrder.slice(turns);
return {
consumed,
remaining,
nextNumber: turns + 1,
nextCard: remaining[0] ?? null,
};
}
// Construit l'en-tête injecté devant le user message envoyé au LLM. Le
// serveur dit *exactement* quelle carte jouer et avec quel numéro — le
// modèle n'a plus à choisir. Combiné au validateur post-gen + patch
// label, l'opération est garantie correcte côté disque même si le
// modèle écrit n'importe quoi.
function buildStateHeader(messages: ChatMessage[], cardOrder: string[]): string {
const { consumed, nextNumber, nextCard } = computeSessionState(messages, cardOrder);
if (nextCard === null) {
// Phase post-narrative : 5 cartes jouées. Le user demande
// typiquement le squelette. Le format CHANGE complètement — plus
// d'étiquette opération, plus de choix A/B/C. Observé sur session
// 14f97d : sans cette consigne explicite, le modèle mimique le
// format des tours précédents et préfixe « *Opération 5/5 —
// synthétiser* » avant le squelette, ce qui est incohérent.
return (
`[ÉTAT SESSION — phase post-narrative]\n` +
`Toutes les cartes (5/5) ont été jouées. La fiction interactive est terminée.\n\n` +
`RÈGLES STRICTES pour ce tour :\n` +
`- N'écris PAS d'étiquette « *Opération N/5 — carte : …* » en tête. C'est le format des tours narratifs précédents, plus le format du livrable.\n` +
`- N'écris PAS de choix A. B. C. — il n'y a plus de tour suivant.\n` +
`- Si l'utilisateur demande le squelette, produis-le DIRECTEMENT au format :\n` +
` **Problématique** (1 phrase) + **Plan 2-3 parties dialectique** + **1 citation du corpus par partie** (alignée avec le titre) + **1 transposition par partie** (auteur DIFFÉRENT du cité).\n` +
`- Termine par une ligne sobre type « Le squelette est là. Tu en fais ce que tu veux. »\n` +
`- Si l'utilisateur demande autre chose (question, précision), réponds sobrement, sans réintroduire une carte.\n\n`
);
}
if (consumed.length === 0) {
// 1er tour : pas d'historique narratif à continuer, mais on impose
// déjà la carte d'ouverture pour que le compteur démarre juste.
return (
`[CARTE À JOUER CE TOUR — décidée par le serveur, NON NÉGOCIABLE]\n` +
`Carte : ${nextCard}. Numéro : ${nextNumber}/5.\n` +
`L'italique de ce tour DOIT s'ouvrir EXACTEMENT par : *Opération ${nextNumber}/5 — ${nextCard} : …*\n\n`
);
}
return (
`[CARTE À JOUER CE TOUR — décidée par le serveur, NON NÉGOCIABLE]\n` +
`Carte : ${nextCard}. Numéro : ${nextNumber}/5.\n` +
`Cartes déjà jouées : ${consumed.join(", ")}.\n` +
`RÈGLES :\n` +
`- L'italique de ce tour DOIT s'ouvrir EXACTEMENT par : *Opération ${nextNumber}/5 — ${nextCard} : …*\n` +
`- Tu n'as PAS le choix de la carte — c'est ${nextCard}, point.\n` +
`- Tu DOIS continuer la MÊME scène narrative que ton tour précédent (mêmes personnages, même lieu).\n\n`
);
}
// État interne exposé via l'endpoint /api/sessions/<id>/state et utilisé
// par le validateur post-génération.
export type SessionProgress = {
consumed: string[];
remaining: string[];
nextNumber: number;
nextCard: string | null;
cardOrder: string[];
done: boolean;
};
export function getSessionProgress(id: string): SessionProgress | null {
const s = sessions.get(id);
if (!s) return null;
const { consumed, remaining, nextNumber, nextCard } = computeSessionState(s.messages, s.cardOrder);
return { consumed, remaining, nextNumber, nextCard, cardOrder: s.cardOrder, done: consumed.length >= 5 };
}
// ----- Validation post-génération de l'état narratif -----
//
// Pendant qu'un retry corrige une citation hallucinée, on en profite pour
// vérifier aussi que la nouvelle réponse respecte la progression des
// cartes. Si elle ne la respecte pas, on rejette comme on rejetterait
// une citation fausse. Même boucle, même budget de retries.
export type StateViolation =
| { kind: "no_label"; expectedCard: string; expectedNumber: number }
| { kind: "wrong_number"; got: number; expectedNumber: number; expectedCard: string }
| { kind: "wrong_card"; got: string; expectedCard: string; expectedNumber: number }
| { kind: "unknown_card"; got: string; expectedCard: string; expectedNumber: number };
// Valide qu'un tour respecte la carte exacte assignée par le serveur.
// Plus de notion de « carte au choix dans les restantes » — le serveur
// décide, le modèle exécute.
function validateTurnState(
content: string,
expectedCard: string | null,
expectedNumber: number,
): StateViolation | null {
if (expectedCard === null) return null; // climax / pas de carte attendue
const m = content.match(/Op[ée]ration\s+(\d)\s*\/\s*5\s*[—-]\s*([a-zéèêàâîôûç]+)/i);
if (!m) return { kind: "no_label", expectedCard, expectedNumber };
const got = parseInt(m[1], 10);
const cardRaw = stripAccents(m[2]);
const card = CARDS.find((c) => stripAccents(c) === cardRaw);
if (!card) return { kind: "unknown_card", got: m[2], expectedCard, expectedNumber };
if (card !== expectedCard) return { kind: "wrong_card", got: card, expectedCard, expectedNumber };
if (got !== expectedNumber) return { kind: "wrong_number", got, expectedNumber, expectedCard };
return null;
}
function buildStateCorrective(v: StateViolation): string {
let problem: string;
switch (v.kind) {
case "no_label":
problem = `Tu n'as pas étiqueté ton tour. C'est obligatoire.`;
break;
case "wrong_number":
problem = `Tu as écrit « Opération ${v.got}/5 » mais le serveur t'a assigné le numéro ${v.expectedNumber}/5.`;
break;
case "wrong_card":
problem = `Tu as joué « ${v.got} » mais le serveur t'a assigné la carte « ${v.expectedCard} ». Tu n'as pas le choix de la carte — c'est le serveur qui décide.`;
break;
case "unknown_card":
problem = `Tu as nommé « ${v.got } » qui n'est pas une carte officielle.`;
break;
}
return `[VÉRIFICATEUR D'ÉTAT] STOP. ${problem}
Le serveur exige cette étiquette EXACTE pour ce tour :
*Opération ${v.expectedNumber}/5 — ${v.expectedCard} : …*
Réécris UNIQUEMENT ta dernière réponse :
- Ouvre l'italique par exactement « *Opération ${v.expectedNumber}/5 — ${v.expectedCard} : */ ».
- Continue la MÊME scène narrative (mêmes personnages, même lieu, même situation).
- Pas de méta-commentaire, pas d'excuse. Juste la version corrigée du tour.`;
}
export function createSession(args: {
id?: string;
systemPrompt: string;
corpus: Citation[];
notion: string;
sujet: string;
cfg: SessionConfig;
}): SessionState {
const id = args.id ?? randomUUID().replace(/-/g, "").slice(0, 16);
const cardOrder = pickCardOrder();
const meta: SessionMeta = {
type: "meta",
id,
notion: args.notion,
sujet: args.sujet,
systemPrompt: args.systemPrompt,
createdAt: Date.now(),
cardOrder,
};
writeMeta(meta);
const state: SessionState = {
id,
meta,
messages: [{ role: "system", content: args.systemPrompt }],
corpus: args.corpus,
cfg: args.cfg,
cardOrder,
};
sessions.set(id, state);
return state;
}
export function resumeSession(args: {
id: string;
corpus: Citation[];
cfg: SessionConfig;
}): SessionState | null {
const loaded = loadFromDisk(args.id);
if (!loaded) return null;
// Sessions pré-cardOrder : synthétise depuis l'historique pour ne pas
// perdre la progression de l'élève. Note : la meta sur disque n'est
// pas réécrite — au prochain createSession on aura le bon format.
const cardOrder = loaded.meta.cardOrder ?? synthesizeCardOrder(loaded.messages);
const state: SessionState = {
id: args.id,
meta: loaded.meta,
messages: loaded.messages,
corpus: args.corpus,
cfg: args.cfg,
cardOrder,
};
sessions.set(args.id, state);
return state;
}
export function getOrResume(args: {
id: string;
corpus: Citation[];
cfg: SessionConfig;
}): SessionState | null {
const cached = sessions.get(args.id);
if (cached) {
cached.cfg = args.cfg; // re-apply current cfg (mode/model/host may have changed)
return cached;
}
if (!sessionExists(args.id)) return null;
return resumeSession(args);
}
export function getSession(id: string): SessionState | null {
return sessions.get(id) ?? null;
}
export function forgetSession(id: string): void {
sessions.delete(id);
}
export type StreamEvent =
| { kind: "attempt-start"; attempt: number }
| { kind: "token"; tok: string }
| { kind: "retry"; attempt: number; reasons: string[]; kinds: Array<"citation" | "state" | "skeleton"> }
| { kind: "patched"; content: string; from?: string; to?: string }
| { kind: "done"; result: TurnResult };
export async function runTurnStreaming(
id: string,
userMsg: string,
onEvent: (e: StreamEvent) => void,
): Promise<TurnResult> {
const s = sessions.get(id);
if (!s) throw new Error(`Session ${id} introuvable en mémoire.`);
const t0 = Date.now();
s.messages.push({ role: "user", content: userMsg });
appendTurn(id, { type: "turn", role: "user", content: userMsg, t: t0 });
// workingMessages = ce qu'on envoie au LLM. Le dernier user message est
// augmenté d'un state header calculé côté serveur (cartes consommées,
// prochaine carte attendue). Le persisté reste l'original — un transcript
// /admin propre, et la régen du state à la reprise reste déterministe.
let attempt = 0;
let content = "";
let lastCheck: ReturnType<typeof verify> | null = null;
let lastStateViolation: StateViolation | null = null;
let lastSkeletonViolations: SkeletonViolation[] = [];
const attempts: TurnResult["attempts"] = [];
// Snapshot de l'état d'AVANT ce tour : on retire le dernier user
// message (le tour qu'on s'apprête à traiter) pour compter combien
// d'assistant turns ont déjà été joués.
const progressBefore = computeSessionState(s.messages.slice(0, -1), s.cardOrder);
const stateHeader = buildStateHeader(s.messages.slice(0, -1), s.cardOrder);
const workingMessages: ChatMessage[] = stateHeader
? [...s.messages.slice(0, -1), { role: "user", content: stateHeader + userMsg }]
: [...s.messages];
while (true) {
onEvent({ kind: "attempt-start", attempt: attempt + 1 });
content = "";
for await (const chunk of streamComplete(workingMessages, s.cfg)) {
if (chunk.kind === "token") {
content += chunk.tok;
onEvent({ kind: "token", tok: chunk.tok });
}
}
lastCheck = verify(content, s.corpus);
const realFaulty = lastCheck.faulty;
lastStateViolation = validateTurnState(
content,
progressBefore.nextCard,
progressBefore.nextNumber,
);
// Validation pédagogique du squelette : ne s'applique que sur les
// tours post-narratifs qui ont la structure d'un squelette
// (Problématique + parties numérotées). Sinon retourne [] sans coût.
const skelResult = progressBefore.nextCard === null
? validateSkeleton(content, s.corpus)
: { violations: [], matched: [] };
lastSkeletonViolations = skelResult.violations;
attempts.push({
attempt: attempt + 1,
realFaulty: realFaulty.length,
stateOk: lastStateViolation === null,
skeletonOk: lastSkeletonViolations.length === 0,
});
const needsRetry = (
realFaulty.length > 0
|| lastStateViolation !== null
|| lastSkeletonViolations.length > 0
) && attempt < s.cfg.maxRetries;
if (!needsRetry) break;
const reasons: string[] = [];
const kinds: Array<"citation" | "state" | "skeleton"> = [];
if (realFaulty.length > 0) {
kinds.push("citation");
for (const f of realFaulty) {
reasons.push(f.reason === "attribution_mismatch"
? `« ${f.quote.raw.slice(0, 60)}… » attribuée à ${f.attributedTo}, en réalité ${f.actualAuteur}`
: `« ${f.quote.raw.slice(0, 60)}… » hors corpus`);
}
}
if (lastStateViolation !== null) {
kinds.push("state");
reasons.push(stateViolationSummary(lastStateViolation));
}
if (lastSkeletonViolations.length > 0) {
kinds.push("skeleton");
for (const v of lastSkeletonViolations) reasons.push(skeletonViolationSummary(v));
}
// Construction d'un prompt correctif combiné. On envoie les
// sections concernées dans un même message — le modèle voit toutes
// les contraintes d'un coup au lieu de subir plusieurs retries en
// série.
const correctiveParts: string[] = [];
if (realFaulty.length > 0) correctiveParts.push(buildCorrectivePrompt(realFaulty));
if (lastStateViolation !== null) correctiveParts.push(buildStateCorrective(lastStateViolation));
if (lastSkeletonViolations.length > 0) correctiveParts.push(buildSkeletonCorrective(lastSkeletonViolations));
workingMessages.push({ role: "assistant", content });
workingMessages.push({ role: "user", content: correctiveParts.join("\n\n") });
attempt++;
onEvent({ kind: "retry", attempt: attempt + 1, reasons, kinds });
}
// Cas spécial post-narrative (squelette) : si le modèle a quand même
// préfixé une étiquette « *Opération N/5 — … */ » (mimicry du format
// narratif), on la strip. Pas de retry, juste un nettoyage. Observé
// sur session 14f97d.
let labelStripped = false;
if (progressBefore.nextCard === null) {
const stripped = content.replace(/^\s*\*\s*Op[ée]ration\s+\d\s*\/\s*5[^\n*]*\*\s*\n+/i, "");
if (stripped !== content) {
content = stripped;
labelStripped = true;
onEvent({ kind: "patched", content, from: "étiquette de tour mimée", to: "(retirée — phase post-narrative)" });
}
}
// Defense-in-depth : si après tous les retries le label est encore
// faux, on patche côté serveur pour matcher exactement la carte
// assignée. Le persisté + le UI verront la version corrigée.
let labelPatch: { from?: string; to?: string } | null = null;
if (lastStateViolation && progressBefore.nextCard !== null) {
const { patched, changed, from, to } = patchOperationLabel(
content,
progressBefore.nextCard,
progressBefore.nextNumber,
);
if (changed) {
content = patched;
labelPatch = { from, to };
lastStateViolation = validateTurnState(
content,
progressBefore.nextCard,
progressBefore.nextNumber,
);
onEvent({ kind: "patched", content, from, to });
}
}
s.messages.push({ role: "assistant", content });
appendTurn(id, {
type: "turn",
role: "assistant",
content,
t: Date.now(),
attempts,
matched: lastCheck!.matched,
totalQuotes: lastCheck!.total,
faulty: lastCheck!.faulty.length,
audits: lastCheck!.audits,
stateViolation: lastStateViolation
? { kind: lastStateViolation.kind, summary: stateViolationSummary(lastStateViolation) }
: undefined,
labelPatch: labelPatch ?? (labelStripped
? { from: "étiquette de tour mimée en phase squelette", to: "(retirée)" }
: undefined),
skeletonViolations: lastSkeletonViolations.length > 0
? lastSkeletonViolations.map((v) => ({
kind: v.kind,
summary: skeletonViolationSummary(v),
}))
: undefined,
});
const result: TurnResult = {
content,
attempts,
finalFaulty: lastCheck!.faulty,
finalStateViolation: lastStateViolation,
finalSkeletonViolations: lastSkeletonViolations,
matched: lastCheck!.matched,
totalQuotes: lastCheck!.total,
};
onEvent({ kind: "done", result });
return result;
}
// Defense-in-depth contre les modèles têtus : si après tous les retries
// l'étiquette est encore fausse, on la patche côté serveur. On respecte
// au maximum la carte choisie par le modèle (si elle est valide), on ne
// touche que ce qui est faux. Observé sur session e659d4… : Qwen3-14B
// produit obstinément « Opération 1/5 » même après 3 retries explicites
// disant « écris 2/5 ».
// Patch défensif : si après tous les retries le label est encore faux,
// on le réécrit pour matcher exactement la carte/numéro assignée par le
// serveur. Pas de respect du « choix narratif » du modèle — la carte
// vient du serveur, point.
function patchOperationLabel(
content: string,
expectedCard: string,
expectedNumber: number,
): { patched: string; changed: boolean; from?: string; to?: string } {
const labelRe = /(\*\s*\(?\s*)?(Op[ée]ration\s+)(\d)(\s*\/\s*5\s*[—-]\s*)([a-zéèêàâîôûç]+)/i;
const m = content.match(labelRe);
if (!m) {
const inject = `*Opération ${expectedNumber}/5 — ${expectedCard} : on poursuit la progression.*\n\n`;
return { patched: inject + content, changed: true, from: "(aucune étiquette)", to: `Opération ${expectedNumber}/5 — ${expectedCard}` };
}
const fullMatch = m[0];
const prefix = m[1] ?? "";
const opWord = m[2];
const gotNum = parseInt(m[3], 10);
const sep = m[4];
const gotCardRaw = m[5];
const gotCardNorm = stripAccents(gotCardRaw);
const gotCard = CARDS.find((c) => stripAccents(c) === gotCardNorm);
if (gotNum === expectedNumber && gotCard === expectedCard) {
return { patched: content, changed: false };
}
const replacement = `${prefix}${opWord}${expectedNumber}${sep}${expectedCard}`;
return {
patched: content.replace(fullMatch, replacement),
changed: true,
from: `Opération ${gotNum}/5 — ${gotCardRaw}`,
to: `Opération ${expectedNumber}/5 — ${expectedCard}`,
};
}
function stateViolationSummary(v: StateViolation): string {
switch (v.kind) {
case "no_label": return `pas d'étiquette « Opération ${v.expectedNumber}/5 — ${v.expectedCard} »`;
case "wrong_number": return `« Opération ${v.got}/5 » au lieu de ${v.expectedNumber}/5`;
case "wrong_card": return `carte « ${v.got} » au lieu de « ${v.expectedCard} »`;
case "unknown_card": return `carte inconnue « ${v.got} » (attendu : ${v.expectedCard})`;
}
}
// Note: la variante non-streamée runTurn() a été supprimée 2026-05 —
// dead code, seule runTurnStreaming est wirée depuis web.ts /stream.