Project Files
src / corpus-page.ts
import { NOTIONS } from "./data/notions";
export function buildCorpusPage(): string {
const notionsJson = JSON.stringify(NOTIONS);
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Enrichir le corpus â Livre dont vous ĂȘtes le hĂ©ros</title>
<style>
:root { --bg:#f7f5ef; --card:#ffffff; --ink:#1f1f1f; --muted:#6b6b6b; --line:#e5e1d6;
--accent:#1565c0; --good:#2e7d32; --warn:#c62828; --soft:#fafaf6; --hint:#ad6800; --hintbg:#fff5d6; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; min-height:100%; background:var(--bg); color:var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; }
body { display:flex; flex-direction:column; align-items:center; padding:32px 16px 80px; }
header { width:100%; max-width:900px; margin-bottom:24px; }
header h1 { font-size:22px; margin:0 0 6px; }
header .sub { font-size:14px; color:var(--muted); line-height:1.5; }
header .links { font-size:12px; color:var(--muted); margin-top:10px; }
header .links a { color:var(--accent); margin-right:14px; }
main { width:100%; max-width:900px; display:flex; flex-direction:column; gap:18px; }
.panel { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px 20px; }
.panel h2 { font-size:15px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
.row { display:flex; gap:8px; align-items:center; }
input[type="text"], textarea, select { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
font:inherit; font-size:14px; background:white; }
textarea { min-height:60px; resize:vertical; width:100%; }
button { padding:10px 16px; font:inherit; font-weight:600; font-size:14px; border:0; border-radius:8px;
color:white; background:var(--ink); cursor:pointer; }
button:disabled { opacity:.4; cursor:not-allowed; }
button.secondary { background:white; color:var(--ink); border:1px solid var(--line); }
button.small { padding:6px 12px; font-size:13px; }
.hit { padding:12px; border:1px solid var(--line); border-radius:8px; margin-bottom:8px; background:white; cursor:pointer; }
.hit:hover { background:var(--soft); border-color:var(--accent); }
.hit .title { font-weight:600; font-size:14px; color:var(--accent); }
.hit .snippet { font-size:13px; color:var(--muted); margin-top:4px; line-height:1.4; }
.candidate { padding:12px; border:1px solid var(--line); border-radius:8px; margin-bottom:8px; background:white; }
.candidate .quote { font-style:italic; line-height:1.5; }
.candidate .meta { font-size:12px; color:var(--muted); margin-top:6px; }
.candidate .actions { margin-top:8px; display:flex; gap:6px; }
.citation-row { padding:10px 0; border-bottom:1px dotted var(--line); display:flex; gap:10px; align-items:flex-start; }
.citation-row:last-child { border-bottom:none; }
.citation-row .body { flex:1; font-size:13px; line-height:1.4; }
.citation-row .body .text { font-style:italic; }
.citation-row .body .ref { font-size:12px; color:var(--muted); margin-top:3px; }
.citation-row .badge { font-size:10px; padding:3px 7px; border-radius:4px; text-transform:uppercase; letter-spacing:.04em; white-space:nowrap; }
.citation-row .badge.active { background:#e8f5e9; color:var(--good); }
.citation-row .badge.pending { background:var(--hintbg); color:var(--hint); }
.citation-row .del { padding:4px 8px; font-size:11px; border:0; background:transparent; color:var(--muted); cursor:pointer; }
.citation-row .del:hover { color:var(--warn); }
.filter { display:flex; gap:8px; margin-bottom:14px; align-items:center; flex-wrap:wrap; }
.filter select { padding:6px 10px; font-size:13px; }
.stats { font-size:12px; color:var(--muted); margin-left:auto; }
.empty { font-size:13px; color:var(--muted); font-style:italic; padding:8px 0; }
.loader { font-size:13px; color:var(--muted); font-style:italic; }
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; align-items:center; justify-content:center; padding:20px; z-index:100; }
.modal { background:white; border-radius:12px; padding:24px; width:100%; max-width:600px; max-height:90vh; overflow-y:auto; }
.modal h3 { margin:0 0 16px; font-size:16px; }
.modal label { display:block; font-size:12px; color:var(--muted); margin-top:12px; margin-bottom:4px; text-transform:uppercase; letter-spacing:.05em; }
.modal input, .modal textarea { width:100%; }
.modal .actions { margin-top:18px; display:flex; gap:8px; justify-content:flex-end; }
.modal .notions-chips { display:flex; flex-wrap:wrap; gap:6px; }
.modal .chip { padding:6px 10px; border:1px solid var(--line); border-radius:16px; font-size:12px; cursor:pointer; background:white; }
.modal .chip.on { background:var(--accent); color:white; border-color:var(--accent); }
.source-info { font-size:12px; color:var(--muted); margin-top:6px; background:var(--soft); padding:8px 10px; border-radius:6px; }
.err { color:var(--warn); font-size:13px; margin-top:6px; }
</style>
</head>
<body>
<header>
<h1>Enrichir le corpus â Philo Bac</h1>
<div class="sub">
Cherche des citations philosophiques dans <strong>Wikisource</strong> (textes du domaine public).
Une citation ajoutée avec une seule source est conservée mais <strong>n'est pas utilisée par le narrateur</strong>
tant qu'une deuxiĂšme source indĂ©pendante ne l'a pas confirmĂ©e â c'est la promesse anti-hallucination du dispositif.
</div>
<div class="links">
<a href="/">â Catalogue de sujets</a>
<a href="/pedagogie" target="_blank">Pourquoi cet outil ?</a>
</div>
</header>
<main>
<section class="panel">
<h2>1. Chercher dans Wikisource</h2>
<div class="row">
<input type="text" id="searchQuery" placeholder="Ex : Bergson langage, Spinoza Ăthique libertĂ©, Sartre existentialismeâŠ" style="flex:1;">
<button id="searchBtn" type="button">Chercher</button>
</div>
<div id="searchStatus" class="loader" style="display:none;"></div>
<div id="searchResults" style="margin-top:12px;"></div>
</section>
<section class="panel" id="extractPanel" style="display:none;">
<h2>2. Citations candidates</h2>
<div id="extractStatus" class="loader" style="display:none;"></div>
<div id="candidates"></div>
</section>
<section class="panel">
<h2>Catalogue corpus</h2>
<div class="filter">
<select id="filterStatus">
<option value="all">Toutes</option>
<option value="active">Actives (â„ 2 sources)</option>
<option value="pending">En attente (1 source)</option>
<option value="user">Mes ajouts</option>
</select>
<select id="filterNotion">
<option value="">Toutes les notions</option>
</select>
<span class="stats" id="stats"></span>
</div>
<div id="catalog"></div>
</section>
</main>
<div id="modalContainer"></div>
<script>
"use strict";
const NOTIONS = ${notionsJson};
const NOTION_TO_SLUG = {};
for (const n of NOTIONS) {
NOTION_TO_SLUG[n] = n.toLowerCase().normalize("NFD").replace(/[Ì-ÍŻ]/g, "").replace(/^(l'|la |le |les )/, "").trim();
}
const SLUG_TO_NOTION = {};
for (const n of NOTIONS) SLUG_TO_NOTION[NOTION_TO_SLUG[n]] = n;
const searchInput = document.getElementById("searchQuery");
const searchBtn = document.getElementById("searchBtn");
const searchStatus = document.getElementById("searchStatus");
const searchResults = document.getElementById("searchResults");
const extractPanel = document.getElementById("extractPanel");
const extractStatus = document.getElementById("extractStatus");
const candidatesEl = document.getElementById("candidates");
const catalogEl = document.getElementById("catalog");
const filterStatus = document.getElementById("filterStatus");
const filterNotion = document.getElementById("filterNotion");
const statsEl = document.getElementById("stats");
const modalContainer = document.getElementById("modalContainer");
let allCitations = [];
let currentQuery = "";
for (const n of NOTIONS) filterNotion.appendChild(el("option", { value: NOTION_TO_SLUG[n] }, n));
function el(tag, attrs, ...kids) {
const n = document.createElement(tag);
if (attrs) for (const k of Object.keys(attrs)) {
if (k === "class") n.className = attrs[k];
else if (k.startsWith("on")) n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, String(attrs[k]));
}
for (const c of kids) {
if (c == null || c === false) continue;
if (typeof c === "string") n.appendChild(document.createTextNode(c));
else n.appendChild(c);
}
return n;
}
function clearNode(n) { while (n.firstChild) n.removeChild(n.firstChild); }
// ---------- Recherche Wikisource ----------
async function doSearch() {
const query = searchInput.value.trim();
if (query.length < 3) { alert("RequĂȘte trop courte."); return; }
currentQuery = query;
searchBtn.disabled = true;
searchBtn.textContent = "âŠ";
searchStatus.style.display = "block";
searchStatus.textContent = "Recherche dans WikisourceâŠ";
clearNode(searchResults);
extractPanel.style.display = "none";
try {
const res = await fetch("/api/corpus/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
searchStatus.style.display = "none";
if (!data.hits || data.hits.length === 0) {
searchResults.appendChild(el("div", { class: "empty" }, "Aucun résultat. Essaie d'autres mots-clés."));
return;
}
for (const h of data.hits) {
const node = el("div", { class: "hit", onclick: () => doExtract(h.title) });
node.appendChild(el("div", { class: "title" }, h.title));
if (h.snippet) node.appendChild(el("div", { class: "snippet" }, h.snippet));
searchResults.appendChild(node);
}
} catch (e) {
searchStatus.style.display = "block";
searchStatus.style.color = "var(--warn)";
searchStatus.textContent = "Erreur : " + e.message;
} finally {
searchBtn.disabled = false;
searchBtn.textContent = "Chercher";
}
}
// ---------- Extraction de citations candidates ----------
async function doExtract(title) {
extractPanel.style.display = "block";
extractStatus.style.display = "block";
extractStatus.style.color = "";
extractStatus.textContent = "Lecture de « " + title + " » + extraction des citations (peut prendre 15-30 s)âŠ";
clearNode(candidatesEl);
window.scrollTo({ top: extractPanel.offsetTop - 20, behavior: "smooth" });
try {
const res = await fetch("/api/corpus/extract", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, query: currentQuery }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
extractStatus.style.display = "none";
if (!data.candidates || data.candidates.length === 0) {
candidatesEl.appendChild(el("div", { class: "empty" }, "Aucune citation pertinente trouvée dans cette page."));
return;
}
for (const c of data.candidates) {
const node = el("div", { class: "candidate" });
node.appendChild(el("div", { class: "quote" }, "« " + c.texte_fr + " »"));
const metaTxt = [c.auteur, c.oeuvre, c.partie].filter(Boolean).join(" â ");
if (metaTxt) node.appendChild(el("div", { class: "meta" }, metaTxt));
const actions = el("div", { class: "actions" });
actions.appendChild(el("button", { class: "small", type: "button",
onclick: () => openAddModal(c, data.sourceUrl, title) }, "Ajouter au corpus â"));
node.appendChild(actions);
candidatesEl.appendChild(node);
}
} catch (e) {
extractStatus.style.color = "var(--warn)";
extractStatus.textContent = "Erreur : " + e.message;
}
}
// ---------- Modal d'ajout ----------
async function openAddModal(candidate, sourceUrl, pageTitle) {
// Pré-classification des notions par le LLM (fire-and-forget,
// on n'attend pas mais on met à jour si ça arrive avant la confirmation)
const notionPromise = fetch("/api/corpus/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ texte_fr: candidate.texte_fr, auteur: candidate.auteur, oeuvre: candidate.oeuvre }),
}).then((r) => r.json()).catch(() => ({ notions: [] }));
const bg = el("div", { class: "modal-bg" });
const modal = el("div", { class: "modal" });
modal.appendChild(el("h3", null, "Ajouter au corpus"));
modal.appendChild(el("label", null, "Citation"));
const taTexte = el("textarea", { id: "m_texte" });
taTexte.value = candidate.texte_fr;
modal.appendChild(taTexte);
modal.appendChild(el("label", null, "Auteur"));
const inAuteur = el("input", { type: "text", id: "m_auteur" });
inAuteur.value = candidate.auteur || "";
modal.appendChild(inAuteur);
modal.appendChild(el("label", null, "Ćuvre"));
const inOeuvre = el("input", { type: "text", id: "m_oeuvre" });
inOeuvre.value = candidate.oeuvre || "";
modal.appendChild(inOeuvre);
modal.appendChild(el("label", null, "Partie / Référence (optionnel)"));
const inPartie = el("input", { type: "text", id: "m_partie", placeholder: "Ex : Livre II, ch. 4" });
inPartie.value = candidate.partie || "";
modal.appendChild(inPartie);
modal.appendChild(el("label", null, "Notions associées (clique pour activer)"));
const chips = el("div", { class: "notions-chips" });
const selectedNotions = new Set();
function buildChip(label, slug) {
return el("button", { class: "chip", type: "button", onclick: function() {
if (selectedNotions.has(slug)) { selectedNotions.delete(slug); this.classList.remove("on"); }
else { selectedNotions.add(slug); this.classList.add("on"); }
} }, label);
}
const chipNodes = {};
for (const n of NOTIONS) {
const slug = NOTION_TO_SLUG[n];
const c = buildChip(n, slug);
chips.appendChild(c);
chipNodes[slug] = c;
}
modal.appendChild(chips);
modal.appendChild(el("div", { class: "source-info" },
"Source : ", el("a", { href: sourceUrl, target: "_blank" }, pageTitle),
" â sera enregistrĂ©e comme premiĂšre source (citation en attente d'une 2e source pour devenir active)."));
const err = el("div", { class: "err", id: "m_err" });
modal.appendChild(err);
const actions = el("div", { class: "actions" });
actions.appendChild(el("button", { class: "secondary", type: "button", onclick: () => bg.remove() }, "Annuler"));
const confirmBtn = el("button", { type: "button" }, "Ajouter au corpus");
actions.appendChild(confirmBtn);
modal.appendChild(actions);
bg.appendChild(modal);
modalContainer.appendChild(bg);
// Auto-classification : marque les notions suggérées
notionPromise.then((cls) => {
const suggested = cls.notions || [];
for (const slug of suggested) {
if (chipNodes[slug]) {
chipNodes[slug].classList.add("on");
selectedNotions.add(slug);
}
}
});
confirmBtn.addEventListener("click", async () => {
err.textContent = "";
const payload = {
texte_fr: taTexte.value.trim(),
auteur: inAuteur.value.trim(),
oeuvre: inOeuvre.value.trim(),
partie: inPartie.value.trim() || undefined,
notions: [...selectedNotions],
source: { url: sourceUrl, fetched: new Date().toISOString().slice(0, 10), kind: "wikisource" },
};
if (!payload.texte_fr || !payload.auteur || !payload.oeuvre) {
err.textContent = "Texte, auteur et Ćuvre sont obligatoires."; return;
}
if (payload.notions.length === 0) {
err.textContent = "Sélectionne au moins une notion."; return;
}
confirmBtn.disabled = true;
confirmBtn.textContent = "âŠ";
try {
const res = await fetch("/api/corpus/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) { err.textContent = "Erreur : " + (data.error || res.statusText); confirmBtn.disabled = false; confirmBtn.textContent = "Ajouter au corpus"; return; }
bg.remove();
await loadCatalog();
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
} catch (e) {
err.textContent = "Erreur : " + e.message;
confirmBtn.disabled = false;
confirmBtn.textContent = "Ajouter au corpus";
}
});
}
// ---------- Catalogue ----------
async function loadCatalog() {
const res = await fetch("/api/corpus");
const data = await res.json();
allCitations = data.citations || [];
renderCatalog();
}
function renderCatalog() {
clearNode(catalogEl);
const fStatus = filterStatus.value;
const fNotion = filterNotion.value;
let visible = allCitations;
if (fStatus === "active") visible = visible.filter((c) => (c.sources || []).length >= 2);
else if (fStatus === "pending") visible = visible.filter((c) => (c.sources || []).length < 2);
else if (fStatus === "user") visible = visible.filter((c) => c.source === "user");
if (fNotion) visible = visible.filter((c) => (c.notions || []).includes(fNotion));
statsEl.textContent = visible.length + " citation" + (visible.length > 1 ? "s" : "");
if (visible.length === 0) {
catalogEl.appendChild(el("div", { class: "empty" }, "Aucune citation."));
return;
}
for (const c of visible) {
const sourceCount = (c.sources || []).length;
const isActive = sourceCount >= 2;
const row = el("div", { class: "citation-row" });
const body = el("div", { class: "body" });
body.appendChild(el("div", { class: "text" }, "« " + c.texte_fr + " »"));
const refTxt = [c.auteur, c.oeuvre, c.partie, c.reference].filter(Boolean).join(", ");
body.appendChild(el("div", { class: "ref" }, refTxt));
const notionsTxt = (c.notions || []).map((s) => SLUG_TO_NOTION[s] || s).join(" · ");
if (notionsTxt) body.appendChild(el("div", { class: "ref" }, notionsTxt));
row.appendChild(body);
row.appendChild(el("span", { class: "badge " + (isActive ? "active" : "pending") },
isActive ? sourceCount + " sources" : "1 source â en attente"));
if (!isActive) {
row.appendChild(el("button", { class: "small secondary", type: "button",
onclick: () => openAddSourceModal(c) }, "+ 2e source"));
}
if (c.source === "user") {
row.appendChild(el("button", { class: "del", type: "button", title: "Supprimer",
onclick: () => deleteCitation(c.id) }, "â"));
}
catalogEl.appendChild(row);
}
}
function openAddSourceModal(citation) {
const bg = el("div", { class: "modal-bg" });
const modal = el("div", { class: "modal" });
modal.appendChild(el("h3", null, "Ajouter une 2e source"));
modal.appendChild(el("div", { class: "source-info" },
"Citation : ", el("em", null, "« " + citation.texte_fr.slice(0, 140) + (citation.texte_fr.length > 140 ? "âŠ" : "") + " »")));
const existingHosts = (citation.sources || []).map(function(s) {
try { return new URL(s.url).host; } catch (e) { return s.url; }
}).join(", ");
modal.appendChild(el("div", { class: "source-info" },
"Sources actuelles : ", existingHosts,
el("br"), "Colle ci-dessous l'URL d'une page sur un AUTRE site oĂč la mĂȘme citation figure. Le systĂšme va la fetch et vĂ©rifier que la citation y est prĂ©sente."));
modal.appendChild(el("label", null, "URL de la 2e source"));
const inUrl = el("input", { type: "text", id: "ms_url", placeholder: "https://..." });
modal.appendChild(inUrl);
const status = el("div", { id: "ms_status" });
modal.appendChild(status);
const actions = el("div", { class: "actions" });
actions.appendChild(el("button", { class: "secondary", type: "button", onclick: () => bg.remove() }, "Annuler"));
const okBtn = el("button", { type: "button" }, "Vérifier et ajouter");
actions.appendChild(okBtn);
modal.appendChild(actions);
bg.appendChild(modal);
modalContainer.appendChild(bg);
inUrl.focus();
okBtn.addEventListener("click", async () => {
const u = inUrl.value.trim();
if (!u) { status.textContent = "URL requise."; status.className = "err"; return; }
okBtn.disabled = true;
okBtn.textContent = "VĂ©rificationâŠ";
status.className = "loader";
status.textContent = "Fetch de l'URL + vĂ©rification du texteâŠ";
try {
const res = await fetch("/api/corpus/" + encodeURIComponent(citation.id) + "/add-source", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: u }),
});
const data = await res.json();
if (res.ok && data.ok) {
status.className = "";
status.style.color = "var(--good)";
status.textContent = "â Citation trouvĂ©e (distance " + (data.bestDist || 0).toFixed(2) + "). Cette citation est maintenant active.";
setTimeout(async () => { bg.remove(); await loadCatalog(); }, 1500);
} else {
status.className = "err";
const reason = data.reason || data.error || "Ăchec de vĂ©rification.";
clearNode(status);
status.appendChild(document.createTextNode(reason));
if (data.bestSnippet) {
status.appendChild(el("br"));
status.appendChild(el("small", null, "Meilleur extrait trouvé : « " + data.bestSnippet + " »"));
}
okBtn.disabled = false;
okBtn.textContent = "Vérifier et ajouter";
}
} catch (e) {
status.className = "err";
status.textContent = "Erreur : " + e.message;
okBtn.disabled = false;
okBtn.textContent = "Vérifier et ajouter";
}
});
}
async function deleteCitation(id) {
if (!confirm("Supprimer cette citation ?")) return;
const res = await fetch("/api/corpus/" + encodeURIComponent(id), { method: "DELETE" });
if (res.ok) await loadCatalog();
}
searchBtn.addEventListener("click", doSearch);
searchInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
filterStatus.addEventListener("change", renderCatalog);
filterNotion.addEventListener("change", renderCatalog);
loadCatalog();
</script>
</body>
</html>`;
}