Project Files
src / sujets.ts
import { mkdirSync, existsSync, readFileSync, appendFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { SUJETS_SEED_JSONL } from "./data";
import { NOTIONS, canonicalNotion } from "./data/notions";
import { complete, type ChatMessage } from "./llm";
import type { SessionConfig } from "./session";
import { extractJson } from "./json-utils";
export type Sujet = {
id: string;
notion: string;
sujet: string;
source: "seed" | "user";
addedAt: number;
deletedAt?: number;
};
const DEFAULT_FILE = join(homedir(), ".livre-heros-bac", "sujets.jsonl");
let storeFile = DEFAULT_FILE;
export function setSujetsFile(path: string): void {
storeFile = path;
}
export function getSujetsFile(): string {
mkdirSync(dirname(storeFile), { recursive: true });
return storeFile;
}
function parseSeed(): Sujet[] {
const out: Sujet[] = [];
for (const line of SUJETS_SEED_JSONL.split("\n")) {
if (!line.trim()) continue;
const raw = JSON.parse(line) as { id: string; notion: string; sujet: string };
out.push({ ...raw, source: "seed", addedAt: 0 });
}
return out;
}
function readAll(): Sujet[] {
const path = getSujetsFile();
if (!existsSync(path)) {
const seed = parseSeed();
for (const s of seed) appendFileSync(path, JSON.stringify(s) + "\n");
return seed;
}
const raw = readFileSync(path, "utf8");
const byId = new Map<string, Sujet>();
for (const seed of parseSeed()) byId.set(seed.id, seed);
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const s = JSON.parse(line) as Sujet;
byId.set(s.id, s);
} catch { /* skip */ }
}
return [...byId.values()];
}
export function listSujets(): Sujet[] {
return readAll().filter((s) => !s.deletedAt);
}
export function listSujetsByNotion(notion: string): Sujet[] {
const canon = canonicalNotion(notion) ?? notion;
return listSujets().filter((s) => s.notion === canon);
}
export function getSujet(id: string): Sujet | null {
return readAll().find((s) => s.id === id) ?? null;
}
export function addSujet(args: { notion: string; sujet: string }): Sujet {
const canon = canonicalNotion(args.notion);
if (!canon) throw new Error(`Notion inconnue : ${args.notion}`);
const trimmed = args.sujet.trim();
if (trimmed.length < 8) throw new Error("Sujet trop court.");
if (trimmed.length > 300) throw new Error("Sujet trop long (max 300).");
const id = "user-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6);
const s: Sujet = { id, notion: canon, sujet: trimmed, source: "user", addedAt: Date.now() };
appendFileSync(getSujetsFile(), JSON.stringify(s) + "\n");
return s;
}
export function deleteSujet(id: string): boolean {
const existing = getSujet(id);
if (!existing) return false;
const tomb: Sujet = { ...existing, deletedAt: Date.now() };
appendFileSync(getSujetsFile(), JSON.stringify(tomb) + "\n");
return true;
}
export function restoreSujet(id: string): boolean {
const existing = getSujet(id);
if (!existing || !existing.deletedAt) return false;
const restored: Sujet = { ...existing, deletedAt: undefined };
appendFileSync(getSujetsFile(), JSON.stringify(restored) + "\n");
return true;
}
// ---------- Classification LLM ----------
export type ClassifyResult = {
notion: string | null;
confidence: number;
};
const NOTIONS_LIST = NOTIONS.join(", ");
export async function classifySujet(sujet: string, cfg: SessionConfig): Promise<ClassifyResult> {
const messages: ChatMessage[] = [
{
role: "system",
content:
"Tu es un classificateur strict de sujets de dissertation philo " +
"terminale. Tu réponds UNIQUEMENT en JSON valide, rien d'autre.",
},
{
role: "user",
content:
`Classe ce sujet de dissertation dans UNE des 17 notions suivantes :\n${NOTIONS_LIST}\n\n` +
`Sujet : « ${sujet} »\n\n` +
`Réponds en JSON strict (un seul objet) :\n` +
`{"notion": "<une des 17 notions, libellé exact>", "confidence": <nombre 0.0-1.0>}\n` +
`Si le sujet est inclassable (hors programme), retourne notion=null et confidence=0.`,
},
];
const raw = await complete(messages, { ...cfg, temperature: 0 });
const json = extractJson(raw);
if (!json) return { notion: null, confidence: 0 };
const notion = canonicalNotion(String(json.notion ?? ""));
const confidence = Number(json.confidence ?? 0);
return {
notion,
confidence: Number.isFinite(confidence) ? Math.max(0, Math.min(1, confidence)) : 0,
};
}
// ---------- Similarité LLM ----------
export type SimilarityResult = {
similar: Sujet | null;
reason: string;
};
export async function findSimilarSujet(
sujet: string,
notion: string,
cfg: SessionConfig,
): Promise<SimilarityResult> {
const candidates = listSujetsByNotion(notion);
if (candidates.length === 0) return { similar: null, reason: "" };
const numbered = candidates.map((c, i) => `${i + 1}. « ${c.sujet} »`).join("\n");
const messages: ChatMessage[] = [
{
role: "system",
content:
"Tu compares un sujet candidat Ă une liste de sujets existants. " +
"Tu dĂ©tectes les quasi-doublons (mĂȘme problĂšme philo, formulations " +
"trÚs proches). Tu réponds UNIQUEMENT en JSON valide.",
},
{
role: "user",
content:
`Candidat : « ${sujet} »\n\n` +
`Sujets existants (mĂȘme notion : ${notion}) :\n${numbered}\n\n` +
`Si le candidat est un quasi-doublon d'un sujet existant, retourne son numéro et explique en une phrase pourquoi.\n` +
`Sinon, retourne null.\n\n` +
`JSON strict :\n` +
`{"match": <numéro ou null>, "reason": "<une phrase courte>"}`,
},
];
const raw = await complete(messages, { ...cfg, temperature: 0 });
const json = extractJson(raw);
if (!json || json.match == null) return { similar: null, reason: "" };
const idx = Number(json.match) - 1;
if (!Number.isInteger(idx) || idx < 0 || idx >= candidates.length) {
return { similar: null, reason: "" };
}
return {
similar: candidates[idx],
reason: String(json.reason ?? "Sujet trĂšs proche."),
};
}
// extractJson est mutualisé dans src/json-utils.ts (partagé avec corpus-builder).