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)
+
+
+
+ Step Brut Statut Provider Conf Coords Essais Query
+
+ ${locRowsHtml}
+
+
+
+
+
+
Appels LLM (Groq)
+ ${llmHtml}
+
+ `;
+
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
@@ -452,6 +529,7 @@ async function openBandModal(maId) {
Thèmes
Latitude
Longitude
+ ${provenanceHtml}
Annuler
@@ -939,6 +1017,7 @@ async function renderGeocoding() {
—
+
@@ -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 = `
+
+
+
+ Modèle: tous
+ llama-3.3-70b-versatile
+ llama-3.1-8b-instant
+
+
+ Uniquement city=null
+
+ Filtrer
+
+
+
+ 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.
+
+
+
+ `;
+ 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/réponse
+
+
+ PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}
+ `;
+ }).join("");
+ document.getElementById("llm-list").innerHTML = rows
+ ? `Date Groupe Modèle Lieu (brut) Extrait Tokens Coût ${rows}
`
+ : `Aucun appel LLM.
`;
+ const totalPages = Math.max(1, Math.ceil(r.total / 50));
+ document.getElementById("llm-pager").innerHTML = `
+ ← Préc.
+ Page ${page} / ${totalPages}
+ = totalPages ? "disabled" : ""}>Suiv. →
+ `;
+ 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*(?:;|$)')