From 2b871584e91f486fbd77183571c255296884a836 Mon Sep 17 00:00:00 2001 From: Nicolas Fryder Date: Thu, 2 Jul 2026 20:10:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20observabilit=C3=A9=20&=20provena?= =?UTF-8?q?nce=20du=20g=C3=A9ocodage=20+=20fix=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/admin/site/app.js | 186 +++++++++++++++++- apps/admin/site/index.html | 1 + apps/api/migrations/011_llm_cache_linkage.sql | 13 ++ apps/api/src/adminRoutes.js | 89 ++++++++- apps/geocoder/src/groq_worker.py | 8 +- apps/geocoder/src/parser.py | 6 +- 6 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 apps/api/migrations/011_llm_cache_linkage.sql diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js index 161ca1b..4c3e98c 100644 --- a/apps/admin/site/app.js +++ b/apps/admin/site/app.js @@ -104,7 +104,7 @@ document.getElementById("logout-btn").addEventListener("click", async () => { // ------------------------------------------------------------------ // 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() { const hash = (location.hash || "#/dashboard").replace("#/", ""); @@ -127,6 +127,7 @@ function router() { monitor: renderMonitor, logs: renderLogs, geocoding: renderGeocoding, + llm: renderLLM, jobs: renderJobs, checkpoints: renderCheckpoints, audit: renderAudit, @@ -429,14 +430,90 @@ async function loadBands() { } async function openBandModal(maId) { - let band; + let band, locations = [], llmCalls = []; try { const r = await api(`/admin/api/bands/${maId}`); band = r.item; + locations = r.locations || []; + llmCalls = r.llm || []; } catch (e) { alert(`Erreur : ${e.message}`); 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) => `
${esc(label)}${val}
`; + + 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 ? `
${esc(l.geocode_error)}
` : ""; + return ` + ${l.step_order}${l.step_label ? ` (${esc(l.step_label)})` : ""} + ${esc(l.location_raw)}${err} + ${esc(l.geocode_status)} + ${esc(l.geocode_provider || "—")} + ${conf} + ${coords} + ${tries} + ${esc(l.geocode_query || "—")} + `; + }).join("") : `Aucune localisation dans band_locations`; + + const llmHtml = llmCalls.length ? llmCalls.map(c => { + const res = c.is_null ? `city=null` : `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`; + const cost = c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"; + return `
+ ${esc(c.model)} · ${esc(c.location_raw || "")} → ${res} · ${c.tokens_in || 0}+${c.tokens_out || 0}tok · ${cost} · ${fmtDate(c.created_at)} +
PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}
+
`; + }).join("") : `
Aucun appel LLM enregistré pour ce groupe.
`; + + const provenanceHtml = ` +
+ 🔎 Provenance & géocodage (lecture seule) + +
+
Metal Archives (crawl)
+ ${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(", ")) : "—")} +
+ +
+
Géocodage (point du groupe)
+ ${srcRow("provider", esc(band.geocode_provider || "—"))} + ${srcRow("query", esc(band.geocode_query || "—"))} + ${srcRow("geocoded_at", fmtDate(band.geocoded_at))} + ${band.geocode_error ? srcRow("erreur", `${esc(band.geocode_error)}`) : ""} +
+ +
+
Localisations (band_locations)
+
+ + + + + ${locRowsHtml} +
StepBrutStatutProviderConfCoordsEssaisQuery
+
+
+ +
+
Appels LLM (Groq)
+ ${llmHtml} +
+
`; + const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; backdrop.innerHTML = ` @@ -452,6 +529,7 @@ async function openBandModal(maId) { + ${provenanceHtml}
+
@@ -1067,6 +1146,7 @@ async function loadGeocoding() { const rawCost = Number(r.llm_cache?.total_cost_usd); const llmCost = (Number.isFinite(rawCost) ? rawCost : 0).toFixed(4); + const llmNull = r.llm_cache?.n_null || 0; const locStatsEl = document.getElementById("loc-stats"); if (locStatsEl) locStatsEl.innerHTML = ` ${statCard("Total locations", locTotal)} @@ -1075,6 +1155,7 @@ async function loadGeocoding() { ${statCard("Manuel", locManual, locManual ? "warn" : "")} ${statCard("Erreurs", locErr, locErr ? "err" : "")} ${statCard("LLM cache", r.llm_cache?.n || 0)} + ${statCard("LLM null", llmNull, llmNull ? "warn" : "")} ${statCard("Coût LLM (USD)", "$" + llmCost)} `; const locBar = document.getElementById("loc-bar"); @@ -1082,6 +1163,15 @@ async function loadGeocoding() { 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(", ")}`; + // 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 = `
Sources géocodage : ${provTxt || "—"}
` + + (modelTxt ? `
Modèles LLM : ${modelTxt}
` : ""); + } + // Ancien pipeline — geocode_queue const total = r.queue.reduce((s, x) => s + x.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 = ` +
+ + + + + +
+

+ 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. +

+
Chargement…
+
+ `; + 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 + ? `null` + : `${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 + ? `${esc(c.band_name || ("#" + c.ma_id))}` + : "—"; + return ` + ${fmtDate(c.created_at)} + ${band} + ${esc(c.model)} + ${esc(c.location_raw || "—")}${c.country ? ` [${esc(c.country)}]` : ""} + ${res} + ${(c.tokens_in || 0)}+${(c.tokens_out || 0)} + ${cost} + + + +
PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}
+ `; + }).join(""); + document.getElementById("llm-list").innerHTML = rows + ? `${rows}
DateGroupeModèleLieu (brut)ExtraitTokensCoût
` + : `
Aucun appel LLM.
`; + const totalPages = Math.max(1, Math.ceil(r.total / 50)); + document.getElementById("llm-pager").innerHTML = ` + + Page ${page} / ${totalPages} + + `; + 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 = `
Erreur : ${esc(e.message)}
`; + } +} + // ------------------------------------------------------------------ // Centre de commandes (Jobs + Géocodeur + Maintenance + Live log) // ------------------------------------------------------------------ diff --git a/apps/admin/site/index.html b/apps/admin/site/index.html index 11e1b80..5fd26cf 100644 --- a/apps/admin/site/index.html +++ b/apps/admin/site/index.html @@ -38,6 +38,7 @@ Monitor Historique Géocodage + LLM Jobs Checkpoints Audit diff --git a/apps/api/migrations/011_llm_cache_linkage.sql b/apps/api/migrations/011_llm_cache_linkage.sql new file mode 100644 index 0000000..c8ee214 --- /dev/null +++ b/apps/api/migrations/011_llm_cache_linkage.sql @@ -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); diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js index 50f61b6..3dd9427 100644 --- a/apps/api/src/adminRoutes.js +++ b/apps/api/src/adminRoutes.js @@ -198,9 +198,31 @@ export default async function adminRoutes(fastify, opts) { if (!Number.isFinite(id) || id < 0) { 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" }); - return { ok: true, item: r.rows[0] }; + return { ok: true, item: r.rows[0], locations: locs.rows, llm: llm.rows }; } catch (err) { fastify.log.error(err); 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) => { try { - const [queue, cache, recent, locations, llm] = await Promise.all([ + const [queue, cache, recent, locations, llm, providers, llmModels] = await Promise.all([ pool.query(` 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 `).catch(() => ({ rows: [] })), 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 - `).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 { ok: true, @@ -461,6 +499,8 @@ export default async function adminRoutes(fastify, opts) { recent: recent.rows, locations: locations.rows, llm_cache: llm.rows[0], + providers: providers.rows, + llm_models: llmModels.rows, }; } catch (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) // ------------------------------------------------------------------ diff --git a/apps/geocoder/src/groq_worker.py b/apps/geocoder/src/groq_worker.py index 02d3ca0..53730a6 100644 --- a/apps/geocoder/src/groq_worker.py +++ b/apps/geocoder/src/groq_worker.py @@ -236,12 +236,14 @@ def main(): INSERT INTO llm_cache (input_hash, model, prompt, response, parsed_city, parsed_country, is_null, - tokens_in, tokens_out, cost_usd) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + tokens_in, tokens_out, cost_usd, + 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 """, (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: diff --git a/apps/geocoder/src/parser.py b/apps/geocoder/src/parser.py index d87a2a1..300e8af 100644 --- a/apps/geocoder/src/parser.py +++ b/apps/geocoder/src/parser.py @@ -87,7 +87,11 @@ COUNTRY_NAMES.update({ }) _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*(?:;|$)')