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:
parent
39e6b2a66f
commit
2b871584e9
6 changed files with 292 additions and 11 deletions
|
|
@ -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)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
13
apps/api/migrations/011_llm_cache_linkage.sql
Normal file
13
apps/api/migrations/011_llm_cache_linkage.sql
Normal 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);
|
||||||
|
|
@ -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)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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*(?:;|$)')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue