feat(admin): observabilité & provenance du géocodage + fix parser

Objectif: pouvoir debugger la fiabilité des données depuis l'admin.

Parser
- fix split "and": _CITY_SPLIT ne coupe plus sur " and "/"&" (cassait
  "Tyne and Wear", "Bosnia and Herzegovina", "Newcastle upon Tyne"). Garde
  uniquement "/" et "\" (séparateurs canoniques Metal Archives).

Traçabilité LLM
- migration 011: llm_cache reçoit ma_id + location_raw + country (+ index) pour
  relier chaque appel Groq au groupe déclencheur
- groq_worker: renseigne ces colonnes à l'insertion

Admin — lecture par groupe
- GET /admin/api/bands/:ma_id renvoie désormais aussi les band_locations (steps,
  statut, provider, confiance, coords, essais, erreur, query) et les appels LLM
  (modèle, extraction, tokens, coût, prompt/réponse)
- modal band: section "Provenance & géocodage" (crawl MA / géocodage / LLM +
  champs verrouillés manuels)

Admin — stats & debug LLM
- GET /admin/api/geocoding: breakdown par provider (avg confiance) + par modèle
  LLM (dont city=null); nouvelle carte "LLM null"
- nouveau GET /admin/api/llm + onglet "LLM": liste filtrable des appels Groq,
  prompt/réponse dépliables, lien vers le groupe

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-07-02 20:10:18 +02:00
parent 39e6b2a66f
commit 2b871584e9
6 changed files with 292 additions and 11 deletions

View file

@ -104,7 +104,7 @@ document.getElementById("logout-btn").addEventListener("click", async () => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Router // Router
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const VIEWS = ["dashboard", "bands", "queue", "runs", "monitor", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"]; const VIEWS = ["dashboard", "bands", "queue", "runs", "monitor", "logs", "geocoding", "llm", "conflicts", "jobs", "checkpoints", "audit"];
function router() { function router() {
const hash = (location.hash || "#/dashboard").replace("#/", ""); const hash = (location.hash || "#/dashboard").replace("#/", "");
@ -127,6 +127,7 @@ function router() {
monitor: renderMonitor, monitor: renderMonitor,
logs: renderLogs, logs: renderLogs,
geocoding: renderGeocoding, geocoding: renderGeocoding,
llm: renderLLM,
jobs: renderJobs, jobs: renderJobs,
checkpoints: renderCheckpoints, checkpoints: renderCheckpoints,
audit: renderAudit, audit: renderAudit,
@ -429,14 +430,90 @@ async function loadBands() {
} }
async function openBandModal(maId) { async function openBandModal(maId) {
let band; let band, locations = [], llmCalls = [];
try { try {
const r = await api(`/admin/api/bands/${maId}`); const r = await api(`/admin/api/bands/${maId}`);
band = r.item; band = r.item;
locations = r.locations || [];
llmCalls = r.llm || [];
} catch (e) { } catch (e) {
alert(`Erreur : ${e.message}`); alert(`Erreur : ${e.message}`);
return; return;
} }
// ---- Provenance & debug (lecture seule) ----
const lockedFields = (() => {
const lf = band.locked_fields;
if (!lf) return [];
if (Array.isArray(lf)) return lf;
if (typeof lf === "object") return Object.keys(lf).filter(k => lf[k]);
try { const p = JSON.parse(lf); return Array.isArray(p) ? p : Object.keys(p); } catch { return []; }
})();
const srcRow = (label, val) => `<div style="display:flex;justify-content:space-between;gap:12px;padding:2px 0;font-size:12px"><span style="color:var(--muted)">${esc(label)}</span><span style="text-align:right">${val}</span></div>`;
const locRowsHtml = locations.length ? locations.map(l => {
const coords = (l.lat != null && l.lon != null) ? `${Number(l.lat).toFixed(3)}, ${Number(l.lon).toFixed(3)}` : "—";
const conf = l.geocode_confidence != null ? Number(l.geocode_confidence).toFixed(2) : "—";
const tries = `${l.geocode_tries_geo || 0}g/${l.geocode_tries_llm || 0}l`;
const err = l.geocode_error ? `<div style="color:var(--err);font-size:11px">${esc(l.geocode_error)}</div>` : "";
return `<tr>
<td>${l.step_order}${l.step_label ? ` <span style="color:var(--muted)">(${esc(l.step_label)})</span>` : ""}</td>
<td>${esc(l.location_raw)}${err}</td>
<td>${esc(l.geocode_status)}</td>
<td>${esc(l.geocode_provider || "—")}</td>
<td>${conf}</td>
<td>${coords}</td>
<td>${tries}</td>
<td style="font-size:11px;color:var(--muted)">${esc(l.geocode_query || "—")}</td>
</tr>`;
}).join("") : `<tr><td colspan="8" style="color:var(--muted)">Aucune localisation dans band_locations</td></tr>`;
const llmHtml = llmCalls.length ? llmCalls.map(c => {
const res = c.is_null ? `<span style="color:var(--warn)">city=null</span>` : `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`;
const cost = c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—";
return `<details style="margin:4px 0;background:rgba(255,255,255,0.03);border-radius:4px;padding:6px 8px">
<summary style="cursor:pointer;font-size:12px">${esc(c.model)} · ${esc(c.location_raw || "")} ${res} · ${c.tokens_in || 0}+${c.tokens_out || 0}tok · ${cost} · ${fmtDate(c.created_at)}</summary>
<div style="margin-top:6px;font-size:11px;color:var(--muted);white-space:pre-wrap">PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}</div>
</details>`;
}).join("") : `<div style="color:var(--muted);font-size:12px">Aucun appel LLM enregistré pour ce groupe.</div>`;
const provenanceHtml = `
<details style="margin-top:14px;border-top:1px solid rgba(255,255,255,0.1);padding-top:10px" open>
<summary style="cursor:pointer;font-weight:600;color:var(--muted)">🔎 Provenance & géocodage (lecture seule)</summary>
<div style="margin-top:8px">
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Metal Archives (crawl)</div>
${srcRow("enrichi", band.enriched ? "oui" : "non")}
${srcRow("crawled_at", fmtDate(band.crawled_at))}
${srcRow("MA créé / modifié", `${fmtDate(band.ma_created_at)} / ${fmtDate(band.ma_modified_at)}`)}
${srcRow("champs verrouillés (manuel)", lockedFields.length ? esc(lockedFields.join(", ")) : "—")}
</div>
<div style="margin-top:10px">
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Géocodage (point du groupe)</div>
${srcRow("provider", esc(band.geocode_provider || "—"))}
${srcRow("query", esc(band.geocode_query || "—"))}
${srcRow("geocoded_at", fmtDate(band.geocoded_at))}
${band.geocode_error ? srcRow("erreur", `<span style="color:var(--err)">${esc(band.geocode_error)}</span>`) : ""}
</div>
<div style="margin-top:10px">
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Localisations (band_locations)</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="text-align:left;color:var(--muted)">
<th>Step</th><th>Brut</th><th>Statut</th><th>Provider</th><th>Conf</th><th>Coords</th><th>Essais</th><th>Query</th>
</tr></thead>
<tbody>${locRowsHtml}</tbody>
</table>
</div>
</div>
<div style="margin-top:10px">
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Appels LLM (Groq)</div>
${llmHtml}
</div>
</details>`;
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop"; backdrop.className = "modal-backdrop";
backdrop.innerHTML = ` backdrop.innerHTML = `
@ -452,6 +529,7 @@ async function openBandModal(maId) {
<label>Thèmes<input type="text" id="m-themes" value="${esc(band.themes || "")}"></label> <label>Thèmes<input type="text" id="m-themes" value="${esc(band.themes || "")}"></label>
<label>Latitude<input type="number" step="any" id="m-lat" value="${band.lat ?? ""}"></label> <label>Latitude<input type="number" step="any" id="m-lat" value="${band.lat ?? ""}"></label>
<label>Longitude<input type="number" step="any" id="m-lon" value="${band.lon ?? ""}"></label> <label>Longitude<input type="number" step="any" id="m-lon" value="${band.lon ?? ""}"></label>
${provenanceHtml}
<div class="modal-error" id="m-error"></div> <div class="modal-error" id="m-error"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-mini" id="m-cancel">Annuler</button> <button class="btn btn-mini" id="m-cancel">Annuler</button>
@ -939,6 +1017,7 @@ async function renderGeocoding() {
<div id="loc-bar" style="width:0%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.6));transition:width 0.4s ease"></div> <div id="loc-bar" style="width:0%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.6));transition:width 0.4s ease"></div>
</div> </div>
<div id="loc-pct" style="font-size:12px;color:var(--muted)"></div> <div id="loc-pct" style="font-size:12px;color:var(--muted)"></div>
<div id="loc-providers" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
<div id="loc-feedback" style="margin-top:8px;font-size:12px"></div> <div id="loc-feedback" style="margin-top:8px;font-size:12px"></div>
</div> </div>
@ -1067,6 +1146,7 @@ async function loadGeocoding() {
const rawCost = Number(r.llm_cache?.total_cost_usd); const rawCost = Number(r.llm_cache?.total_cost_usd);
const llmCost = (Number.isFinite(rawCost) ? rawCost : 0).toFixed(4); const llmCost = (Number.isFinite(rawCost) ? rawCost : 0).toFixed(4);
const llmNull = r.llm_cache?.n_null || 0;
const locStatsEl = document.getElementById("loc-stats"); const locStatsEl = document.getElementById("loc-stats");
if (locStatsEl) locStatsEl.innerHTML = ` if (locStatsEl) locStatsEl.innerHTML = `
${statCard("Total locations", locTotal)} ${statCard("Total locations", locTotal)}
@ -1075,6 +1155,7 @@ async function loadGeocoding() {
${statCard("Manuel", locManual, locManual ? "warn" : "")} ${statCard("Manuel", locManual, locManual ? "warn" : "")}
${statCard("Erreurs", locErr, locErr ? "err" : "")} ${statCard("Erreurs", locErr, locErr ? "err" : "")}
${statCard("LLM cache", r.llm_cache?.n || 0)} ${statCard("LLM cache", r.llm_cache?.n || 0)}
${statCard("LLM null", llmNull, llmNull ? "warn" : "")}
${statCard("Coût LLM (USD)", "$" + llmCost)} ${statCard("Coût LLM (USD)", "$" + llmCost)}
`; `;
const locBar = document.getElementById("loc-bar"); const locBar = document.getElementById("loc-bar");
@ -1082,6 +1163,15 @@ async function loadGeocoding() {
const locPctEl = document.getElementById("loc-pct"); const locPctEl = document.getElementById("loc-pct");
if (locPctEl) locPctEl.textContent = `${locPct}% — ${locDone.toLocaleString("fr-FR")} / ${locTotal.toLocaleString("fr-FR")} — statuts: ${locs.map((x) => `${LOC_STATUS_LABELS[x.status] || x.status} ${x.n}`).join(", ")}`; if (locPctEl) locPctEl.textContent = `${locPct}% — ${locDone.toLocaleString("fr-FR")} / ${locTotal.toLocaleString("fr-FR")} — statuts: ${locs.map((x) => `${LOC_STATUS_LABELS[x.status] || x.status} ${x.n}`).join(", ")}`;
// Breakdown par provider (source du géocodage) + par modèle LLM
const provEl = document.getElementById("loc-providers");
if (provEl) {
const provTxt = (r.providers || []).map((p) => `${esc(p.provider)}: ${p.n.toLocaleString("fr-FR")}${p.avg_conf != null ? ` (conf~${p.avg_conf})` : ""}`).join(" · ");
const modelTxt = (r.llm_models || []).map((m) => `${esc(m.model)}: ${m.n} (null ${m.n_null})`).join(" · ");
provEl.innerHTML = `<div><strong>Sources géocodage :</strong> ${provTxt || "—"}</div>` +
(modelTxt ? `<div style="margin-top:4px"><strong>Modèles LLM :</strong> ${modelTxt}</div>` : "");
}
// Ancien pipeline — geocode_queue // Ancien pipeline — geocode_queue
const total = r.queue.reduce((s, x) => s + x.n, 0); const total = r.queue.reduce((s, x) => s + x.n, 0);
const done = r.queue.find((x) => x.status === "done")?.n || 0; const done = r.queue.find((x) => x.status === "done")?.n || 0;
@ -1125,6 +1215,98 @@ async function loadGeocoding() {
} }
} }
// ------------------------------------------------------------------
// LLM — debug des appels Groq (désambiguïsation géocodage)
// ------------------------------------------------------------------
async function renderLLM() {
content().innerHTML = `
<div class="toolbar">
<input type="text" id="llm-q" placeholder="Filtrer par lieu / ville…" style="min-width:220px">
<select id="llm-model">
<option value="">Modèle: tous</option>
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:13px">
<input type="checkbox" id="llm-null"> Uniquement city=null
</label>
<button class="btn" id="llm-apply">Filtrer</button>
<span id="llm-status" style="font-size:12px;color:var(--muted)"></span>
</div>
<p style="font-size:12px;color:var(--muted);margin:0 0 12px">
Chaque appel Groq est mis en cache (clé = modèle + pays + lieu) et relié au groupe déclencheur.
Déplie une ligne pour voir le prompt exact et la réponse brute du modèle.
</p>
<div id="llm-list" class="table-wrap"><div class="loading">Chargement</div></div>
<div class="pager" id="llm-pager"></div>
`;
if (!state.llm) state.llm = { page: 1 };
document.getElementById("llm-apply").addEventListener("click", () => { state.llm.page = 1; loadLLM(); });
// Délégation attachée une seule fois sur le conteneur persistant.
document.getElementById("llm-list").addEventListener("click", (e) => {
const det = e.target.closest(".llm-detail");
if (det) {
const row = document.querySelector(`.llm-detail-row[data-id="${det.dataset.id}"]`);
if (row) row.style.display = row.style.display === "none" ? "" : "none";
return;
}
const bandLink = e.target.closest(".llm-band");
if (bandLink) { e.preventDefault(); openBandModal(Number(bandLink.dataset.ma)); }
});
await loadLLM();
}
async function loadLLM() {
const q = document.getElementById("llm-q")?.value.trim() || "";
const model = document.getElementById("llm-model")?.value || "";
const onlyNull = document.getElementById("llm-null")?.checked ? "1" : "";
const page = state.llm?.page || 1;
const params = new URLSearchParams({ page: String(page), pageSize: "50" });
if (q) params.set("q", q);
if (model) params.set("model", model);
if (onlyNull) params.set("only_null", "1");
try {
const r = await api(`/admin/api/llm?${params.toString()}`);
const st = document.getElementById("llm-status");
if (st) st.textContent = `${r.total.toLocaleString("fr-FR")} appel(s) LLM`;
const rows = (r.items || []).map((c) => {
const res = c.is_null
? `<span style="color:var(--warn)">null</span>`
: `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`;
const cost = c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—";
const band = c.ma_id
? `<a href="#" data-ma="${c.ma_id}" class="llm-band">${esc(c.band_name || ("#" + c.ma_id))}</a>`
: "—";
return `<tr>
<td>${fmtDate(c.created_at)}</td>
<td>${band}</td>
<td style="font-size:11px">${esc(c.model)}</td>
<td>${esc(c.location_raw || "—")}${c.country ? ` <span style="color:var(--muted)">[${esc(c.country)}]</span>` : ""}</td>
<td>${res}</td>
<td>${(c.tokens_in || 0)}+${(c.tokens_out || 0)}</td>
<td>${cost}</td>
<td><button class="btn btn-mini llm-detail" data-id="${c.id}">Prompt/réponse</button></td>
</tr>
<tr class="llm-detail-row" data-id="${c.id}" style="display:none"><td colspan="8">
<div style="font-size:11px;color:var(--muted);white-space:pre-wrap;background:rgba(255,255,255,0.03);padding:8px;border-radius:4px">PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}</div>
</td></tr>`;
}).join("");
document.getElementById("llm-list").innerHTML = rows
? `<table><thead><tr><th>Date</th><th>Groupe</th><th>Modèle</th><th>Lieu (brut)</th><th>Extrait</th><th>Tokens</th><th>Coût</th><th></th></tr></thead><tbody>${rows}</tbody></table>`
: `<div class="empty">Aucun appel LLM.</div>`;
const totalPages = Math.max(1, Math.ceil(r.total / 50));
document.getElementById("llm-pager").innerHTML = `
<button class="btn btn-mini" id="llm-prev" ${page <= 1 ? "disabled" : ""}> Préc.</button>
<span>Page ${page} / ${totalPages}</span>
<button class="btn btn-mini" id="llm-next" ${page >= totalPages ? "disabled" : ""}>Suiv. </button>
`;
document.getElementById("llm-prev")?.addEventListener("click", () => { state.llm.page = page - 1; loadLLM(); });
document.getElementById("llm-next")?.addEventListener("click", () => { state.llm.page = page + 1; loadLLM(); });
} catch (e) {
document.getElementById("llm-list").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Centre de commandes (Jobs + Géocodeur + Maintenance + Live log) // Centre de commandes (Jobs + Géocodeur + Maintenance + Live log)
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View file

@ -38,6 +38,7 @@
<a href="#/monitor" data-view="monitor">Monitor</a> <a href="#/monitor" data-view="monitor">Monitor</a>
<a href="#/logs" data-view="logs">Historique</a> <a href="#/logs" data-view="logs">Historique</a>
<a href="#/geocoding" data-view="geocoding">Géocodage</a> <a href="#/geocoding" data-view="geocoding">Géocodage</a>
<a href="#/llm" data-view="llm">LLM</a>
<a href="#/jobs" data-view="jobs">Jobs</a> <a href="#/jobs" data-view="jobs">Jobs</a>
<a href="#/checkpoints" data-view="checkpoints">Checkpoints</a> <a href="#/checkpoints" data-view="checkpoints">Checkpoints</a>
<a href="#/audit" data-view="audit">Audit</a> <a href="#/audit" data-view="audit">Audit</a>

View file

@ -0,0 +1,13 @@
-- 011_llm_cache_linkage.sql
-- llm_cache était keyé uniquement par input_hash (sha256 de model|country|raw),
-- sans lien vers le groupe qui a déclenché l'appel. Impossible donc de retrouver
-- les appels LLM d'un groupe depuis l'admin. On ajoute la provenance pour le
-- debug (ma_id = premier groupe déclencheur ; location_raw + country lisibles).
ALTER TABLE llm_cache
ADD COLUMN IF NOT EXISTS ma_id BIGINT,
ADD COLUMN IF NOT EXISTS location_raw TEXT,
ADD COLUMN IF NOT EXISTS country TEXT;
CREATE INDEX IF NOT EXISTS idx_llm_cache_ma_id ON llm_cache (ma_id);
CREATE INDEX IF NOT EXISTS idx_llm_cache_loc ON llm_cache (lower(location_raw), country);

View file

@ -198,9 +198,31 @@ export default async function adminRoutes(fastify, opts) {
if (!Number.isFinite(id) || id < 0) { if (!Number.isFinite(id) || id < 0) {
return reply.code(400).send({ ok: false, error: "bad ma_id" }); return reply.code(400).send({ ok: false, error: "bad ma_id" });
} }
const r = await pool.query(`SELECT * FROM bands WHERE ma_id = $1 LIMIT 1`, [id]); const [r, locs, llm] = await Promise.all([
pool.query(`SELECT * FROM bands WHERE ma_id = $1 LIMIT 1`, [id]),
pool.query(`
SELECT id, step_order, step_label, location_raw, is_country_only,
lat, lon, geocode_status, geocode_query, geocode_provider,
geocode_confidence, geocode_error, geocode_tries_geo,
geocode_tries_llm, geocode_next_at, updated_at
FROM band_locations
WHERE ma_id = $1
ORDER BY step_order ASC, id ASC
`, [id]).catch(() => ({ rows: [] })),
pool.query(`
SELECT id, model, location_raw, country, parsed_city, parsed_country,
is_null, tokens_in, tokens_out, cost_usd, prompt, response, created_at
FROM llm_cache
WHERE ma_id = $1
OR lower(location_raw) IN (
SELECT lower(location_raw) FROM band_locations WHERE ma_id = $1
)
ORDER BY created_at DESC
LIMIT 50
`, [id]).catch(() => ({ rows: [] })),
]);
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" }); if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" });
return { ok: true, item: r.rows[0] }; return { ok: true, item: r.rows[0], locations: locs.rows, llm: llm.rows };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur récupération band" }); return reply.code(500).send({ ok: false, error: "Erreur récupération band" });
@ -432,7 +454,7 @@ export default async function adminRoutes(fastify, opts) {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
fastify.get("/admin/api/geocoding", async (req, reply) => { fastify.get("/admin/api/geocoding", async (req, reply) => {
try { try {
const [queue, cache, recent, locations, llm] = await Promise.all([ const [queue, cache, recent, locations, llm, providers, llmModels] = await Promise.all([
pool.query(` pool.query(`
SELECT status, count(*)::int AS n FROM geocode_queue GROUP BY status ORDER BY n DESC SELECT status, count(*)::int AS n FROM geocode_queue GROUP BY status ORDER BY n DESC
`), `),
@ -450,9 +472,25 @@ export default async function adminRoutes(fastify, opts) {
GROUP BY geocode_status ORDER BY n DESC GROUP BY geocode_status ORDER BY n DESC
`).catch(() => ({ rows: [] })), `).catch(() => ({ rows: [] })),
pool.query(` pool.query(`
SELECT count(*)::int AS n, sum(cost_usd)::numeric(10,6) AS total_cost_usd SELECT count(*)::int AS n,
sum(cost_usd)::numeric(10,6) AS total_cost_usd,
count(*) FILTER (WHERE is_null)::int AS n_null
FROM llm_cache FROM llm_cache
`).catch(() => ({ rows: [{ n: 0, total_cost_usd: 0 }] })), `).catch(() => ({ rows: [{ n: 0, total_cost_usd: 0, n_null: 0 }] })),
pool.query(`
SELECT COALESCE(geocode_provider, '(en attente)') AS provider,
count(*)::int AS n,
round(avg(geocode_confidence)::numeric, 2) AS avg_conf
FROM band_locations
GROUP BY geocode_provider ORDER BY n DESC
`).catch(() => ({ rows: [] })),
pool.query(`
SELECT model,
count(*)::int AS n,
count(*) FILTER (WHERE is_null)::int AS n_null,
sum(cost_usd)::numeric(10,6) AS cost
FROM llm_cache GROUP BY model ORDER BY n DESC
`).catch(() => ({ rows: [] })),
]); ]);
return { return {
ok: true, ok: true,
@ -461,6 +499,8 @@ export default async function adminRoutes(fastify, opts) {
recent: recent.rows, recent: recent.rows,
locations: locations.rows, locations: locations.rows,
llm_cache: llm.rows[0], llm_cache: llm.rows[0],
providers: providers.rows,
llm_models: llmModels.rows,
}; };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
@ -468,6 +508,45 @@ export default async function adminRoutes(fastify, opts) {
} }
}); });
// Appels LLM récents (debug). Filtres: model, only_null=1, q (location).
fastify.get("/admin/api/llm", async (req, reply) => {
try {
const { model, only_null, q } = req.query || {};
const { pageSize, offset } = pagination(req.query || {});
const where = [];
const vals = [];
let i = 1;
if (model) { where.push(`model = $${i}`); vals.push(String(model)); i++; }
if (only_null === "1" || only_null === "true") where.push(`is_null = true`);
if (q) {
const qq = String(q).trim().slice(0, 100);
if (qq.length >= 1) {
where.push(`(location_raw ILIKE $${i} OR parsed_city ILIKE $${i})`);
vals.push(`%${qq}%`); i++;
}
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const [rows, cnt] = await Promise.all([
pool.query(`
SELECT lc.id, lc.ma_id, b.name AS band_name, lc.model, lc.location_raw,
lc.country, lc.parsed_city, lc.parsed_country, lc.is_null,
lc.tokens_in, lc.tokens_out, lc.cost_usd, lc.prompt, lc.response,
lc.created_at
FROM llm_cache lc
LEFT JOIN bands b ON b.ma_id = lc.ma_id
${whereSql}
ORDER BY lc.created_at DESC
LIMIT $${i} OFFSET $${i + 1}
`, [...vals, pageSize, offset]),
pool.query(`SELECT count(*)::int AS total FROM llm_cache lc ${whereSql}`, vals),
]);
return { ok: true, items: rows.rows, total: cnt.rows[0].total };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur LLM debug" });
}
});
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Job triggers (déclenche un job dans le crawler sans redéployer) // Job triggers (déclenche un job dans le crawler sans redéployer)
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View file

@ -236,12 +236,14 @@ def main():
INSERT INTO llm_cache INSERT INTO llm_cache
(input_hash, model, prompt, response, (input_hash, model, prompt, response,
parsed_city, parsed_country, is_null, parsed_city, parsed_country, is_null,
tokens_in, tokens_out, cost_usd) tokens_in, tokens_out, cost_usd,
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ma_id, location_raw, country)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (input_hash) DO NOTHING ON CONFLICT (input_hash) DO NOTHING
""", """,
(cache_hash, chosen_model, prompt_text, response_text, (cache_hash, chosen_model, prompt_text, response_text,
city, iso2, is_null, tok_in, tok_out, cost_usd), city, iso2, is_null, tok_in, tok_out, cost_usd,
ma_id, location_raw, country),
) )
if is_null: if is_null:

View file

@ -87,7 +87,11 @@ COUNTRY_NAMES.update({
}) })
_ISO2_RE = re.compile(r'^[A-Z]{2}$') _ISO2_RE = re.compile(r'^[A-Z]{2}$')
_CITY_SPLIT = re.compile(r'\s*/\s*|\s*\\\s*|\s+and\s+|\s*&\s*', re.IGNORECASE) # Séparateurs de villes multiples DANS un même step. On garde uniquement "/" et
# "\" (séparateurs canoniques Metal Archives). On N'utilise PAS " and " / "&" :
# ils cassent les noms composés ("Tyne and Wear", "Bosnia and Herzegovina",
# "Newcastle upon Tyne"). Fiabilité > exhaustivité.
_CITY_SPLIT = re.compile(r'\s*/\s*|\s*\\\s*')
_STEP_RE = re.compile(r'([^;(]+?)(?:\s*\(([^)]+?)\))?\s*(?:;|$)') _STEP_RE = re.compile(r'([^;(]+?)(?:\s*\(([^)]+?)\))?\s*(?:;|$)')