feat(geocoding): boutons UI pour déclencher l'enqueue band_locations
- enqueue.py devient un daemon qui poll job_triggers (type geocoder_enqueue) toutes les 15s — plus de commande manuelle à lancer - API : geocoder_enqueue ajouté aux job types autorisés - Admin UI page Géocodage : bouton "Lancer l'enqueue" (vert) - Admin UI Centre de commandes : même bouton dans la section Géocodeur - docker-compose : geocoder-enqueue passe à restart: unless-stopped Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c39a8513f8
commit
dba6b9f730
5 changed files with 173 additions and 90 deletions
|
|
@ -928,6 +928,7 @@ async function renderGeocoding() {
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:10px">
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:10px">
|
||||||
<h2 style="margin:0">Nouveau pipeline — band_locations</h2>
|
<h2 style="margin:0">Nouveau pipeline — band_locations</h2>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-mini" id="loc-enqueue" style="border-color:rgba(63,174,90,0.5);color:var(--ok)">Lancer l'enqueue</button>
|
||||||
<button class="btn btn-mini" id="loc-reset-errors">Reset erreurs</button>
|
<button class="btn btn-mini" id="loc-reset-errors">Reset erreurs</button>
|
||||||
<button class="btn btn-mini" id="loc-reset-llm">Reset LLM needed</button>
|
<button class="btn btn-mini" id="loc-reset-llm">Reset LLM needed</button>
|
||||||
<button class="btn btn-mini" id="loc-requeue-all">Re-queue tout</button>
|
<button class="btn btn-mini" id="loc-requeue-all">Re-queue tout</button>
|
||||||
|
|
@ -982,6 +983,22 @@ async function renderGeocoding() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.getElementById("loc-enqueue").addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("loc-enqueue");
|
||||||
|
btn.disabled = true;
|
||||||
|
const fb = document.getElementById("loc-feedback");
|
||||||
|
fb.textContent = "Job envoyé, le daemon va démarrer l'enqueue (peut prendre quelques secondes)…";
|
||||||
|
fb.style.color = "var(--muted)";
|
||||||
|
try {
|
||||||
|
await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: "geocoder_enqueue" }) });
|
||||||
|
fb.textContent = "✓ Job geocoder_enqueue créé — le daemon parse les bands en arrière-plan.";
|
||||||
|
fb.style.color = "var(--ok)";
|
||||||
|
setTimeout(() => loadGeocoding(), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
|
||||||
|
} finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
makeLocAction("loc-reset-errors", "loc-feedback", "/admin/api/locations/reset-errors", {}, null);
|
makeLocAction("loc-reset-errors", "loc-feedback", "/admin/api/locations/reset-errors", {}, null);
|
||||||
makeLocAction("loc-reset-llm", "loc-feedback", "/admin/api/locations/reset-llm", {}, "Remettre en queue les entrées llm_needed et manual ?");
|
makeLocAction("loc-reset-llm", "loc-feedback", "/admin/api/locations/reset-llm", {}, "Remettre en queue les entrées llm_needed et manual ?");
|
||||||
makeLocAction("loc-requeue-all", "loc-feedback", "/admin/api/locations/requeue-all", { include_done: true }, "Re-queue TOUTES les band_locations (erreurs + LLM + manual + done) ?");
|
makeLocAction("loc-requeue-all", "loc-feedback", "/admin/api/locations/requeue-all", { include_done: true }, "Re-queue TOUTES les band_locations (erreurs + LLM + manual + done) ?");
|
||||||
|
|
@ -1127,10 +1144,11 @@ async function renderJobs() {
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>🗺 Géocodeur</h2>
|
<h2>🗺 Géocodeur</h2>
|
||||||
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">Le géocodeur tourne en continu — ces actions modifient la queue directement.</p>
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">Le géocodeur et le worker Groq tournent en continu. L'enqueue parse les bands en band_locations.</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
<button class="btn" id="cmd-reset-errors">🔄 Réinitialiser les erreurs de géocodage</button>
|
<button class="btn" id="cmd-enqueue" style="border-color:rgba(63,174,90,0.5);color:var(--ok)">▶ Lancer l'enqueue band_locations</button>
|
||||||
<button class="btn" id="cmd-requeue-all" style="border-color:rgba(198,26,26,0.5)">♻️ Re-géocoder tout (erreurs + déjà fait)</button>
|
<button class="btn" id="cmd-reset-errors">🔄 Reset erreurs geocode_queue</button>
|
||||||
|
<button class="btn" id="cmd-requeue-all" style="border-color:rgba(198,26,26,0.5)">♻️ Re-géocoder tout geocode_queue</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="geo-cmd-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
<div id="geo-cmd-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1177,6 +1195,22 @@ async function renderJobs() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Geocoder enqueue
|
||||||
|
document.getElementById("cmd-enqueue").addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("cmd-enqueue");
|
||||||
|
btn.disabled = true;
|
||||||
|
const fb = document.getElementById("geo-cmd-feedback");
|
||||||
|
fb.textContent = "Envoi du job…"; fb.style.color = "var(--muted)";
|
||||||
|
try {
|
||||||
|
await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: "geocoder_enqueue" }) });
|
||||||
|
fb.textContent = "✓ Job geocoder_enqueue créé — le daemon parse les bands en arrière-plan.";
|
||||||
|
fb.style.color = "var(--ok)";
|
||||||
|
await loadCmdLogTail();
|
||||||
|
} catch (e) {
|
||||||
|
fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
|
||||||
|
} finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
// Geocoder reset errors
|
// Geocoder reset errors
|
||||||
document.getElementById("cmd-reset-errors").addEventListener("click", async () => {
|
document.getElementById("cmd-reset-errors").addEventListener("click", async () => {
|
||||||
const btn = document.getElementById("cmd-reset-errors");
|
const btn = document.getElementById("cmd-reset-errors");
|
||||||
|
|
|
||||||
|
|
@ -487,7 +487,7 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
fastify.post("/admin/api/job-triggers", async (req, reply) => {
|
fastify.post("/admin/api/job-triggers", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const { job_type } = req.body || {};
|
const { job_type } = req.body || {};
|
||||||
const allowed = ["enrich", "incremental", "full_crawl"];
|
const allowed = ["enrich", "incremental", "full_crawl", "geocoder_enqueue"];
|
||||||
if (!allowed.includes(job_type)) {
|
if (!allowed.includes(job_type)) {
|
||||||
return reply.code(400).send({ ok: false, error: `job_type doit être: ${allowed.join(", ")}` });
|
return reply.code(400).send({ ok: false, error: `job_type doit être: ${allowed.join(", ")}` });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,24 @@
|
||||||
"""
|
"""
|
||||||
enqueue.py — Peuple band_locations depuis bands.location_text.
|
enqueue.py — Daemon qui attend des job_triggers de type 'geocoder_enqueue'
|
||||||
|
et peuple band_locations depuis bands.location_text.
|
||||||
|
|
||||||
Usage :
|
Déclenché depuis l'UI admin → POST /admin/api/job-triggers {job_type: "geocoder_enqueue"}
|
||||||
python src/enqueue.py
|
Tourne en continu (restart: unless-stopped), poll toutes les 15s.
|
||||||
|
|
||||||
Actions :
|
|
||||||
1. Parse location_text de tous les bands via parser.parse_location_text
|
|
||||||
2. INSERT INTO band_locations (ON CONFLICT DO NOTHING → idempotent)
|
|
||||||
3. Résout immédiatement les is_country_only avec centroïdes hardcodés
|
|
||||||
4. Continue à alimenter geocode_queue (ancien pipeline) pour les bands sans
|
|
||||||
location_raw géocodable (backward compat carte existante)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import sys
|
|
||||||
|
|
||||||
# Quand lancé comme "python src/enqueue.py", sys.path[0] = src/
|
|
||||||
from parser import (
|
from parser import (
|
||||||
parse_location_text,
|
parse_location_text,
|
||||||
country_centroid,
|
country_centroid,
|
||||||
COUNTRY_NAME_TO_ISO2,
|
COUNTRY_NAME_TO_ISO2,
|
||||||
COUNTRY_NAMES,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
POLL_INTERVAL = int(os.environ.get("GEOCODE_ENQUEUE_POLL", "15"))
|
||||||
|
BATCH = int(os.environ.get("GEOCODE_ENQUEUE_BATCH", "100000"))
|
||||||
|
|
||||||
|
|
||||||
def resolve_iso2(location_raw: str, band_country: str | None) -> str | None:
|
def resolve_iso2(location_raw: str, band_country: str | None) -> str | None:
|
||||||
t = location_raw.strip()
|
t = location_raw.strip()
|
||||||
|
|
@ -37,89 +32,141 @@ def resolve_iso2(location_raw: str, band_country: str | None) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def run_enqueue(cur) -> tuple[int, int, int]:
|
||||||
dsn = os.environ["DATABASE_URL"]
|
"""Parse tous les bands et peuple band_locations. Retourne (inserted, country_fixed, skipped)."""
|
||||||
batch = int(os.environ.get("GEOCODE_ENQUEUE_BATCH", "50000"))
|
cur.execute(
|
||||||
|
"SELECT ma_id, country, location_text FROM bands "
|
||||||
|
"WHERE location_text IS NOT NULL AND location_text <> '' "
|
||||||
|
"ORDER BY ma_id ASC LIMIT %s",
|
||||||
|
(BATCH,),
|
||||||
|
)
|
||||||
|
bands = cur.fetchall()
|
||||||
|
print(f"[enqueue] {len(bands)} bands à parser")
|
||||||
|
|
||||||
|
inserted = country_fixed = skipped = 0
|
||||||
|
|
||||||
|
for ma_id, country, location_text in bands:
|
||||||
|
steps = parse_location_text(location_text, country)
|
||||||
|
if not steps:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO band_locations
|
||||||
|
(ma_id, step_order, step_label, location_raw,
|
||||||
|
is_country_only, geocode_status, geocode_next_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, 'queued', now())
|
||||||
|
ON CONFLICT (ma_id, step_order, location_raw) DO NOTHING
|
||||||
|
""",
|
||||||
|
(ma_id, step['step_order'], step['step_label'],
|
||||||
|
step['location_raw'], step['is_country_only']),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
inserted += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[enqueue] WARN insert ma_id={ma_id}: {exc}")
|
||||||
|
|
||||||
|
# Résoudre immédiatement les entrées pays-seulement
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT bl.id, bl.location_raw, b.country
|
||||||
|
FROM band_locations bl
|
||||||
|
JOIN bands b ON b.ma_id = bl.ma_id
|
||||||
|
WHERE bl.geocode_status = 'queued'
|
||||||
|
AND bl.is_country_only = TRUE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for loc_id, location_raw, band_country in cur.fetchall():
|
||||||
|
iso2 = resolve_iso2(location_raw, band_country)
|
||||||
|
coords = country_centroid(iso2) if iso2 else None
|
||||||
|
if coords:
|
||||||
|
lat, lon = coords
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE band_locations
|
||||||
|
SET lat=%s, lon=%s,
|
||||||
|
geocode_status='country_only',
|
||||||
|
geocode_provider='country_centroid',
|
||||||
|
geocode_confidence=0.1,
|
||||||
|
geocode_query=%s,
|
||||||
|
updated_at=now()
|
||||||
|
WHERE id=%s
|
||||||
|
""",
|
||||||
|
(lat, lon, iso2, loc_id),
|
||||||
|
)
|
||||||
|
country_fixed += 1
|
||||||
|
else:
|
||||||
|
print(f"[enqueue] no centroid id={loc_id} raw='{location_raw}'")
|
||||||
|
|
||||||
|
return inserted, country_fixed, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dsn = os.environ["DATABASE_URL"]
|
||||||
conn = psycopg2.connect(dsn)
|
conn = psycopg2.connect(dsn)
|
||||||
conn.autocommit = True
|
conn.autocommit = True
|
||||||
|
|
||||||
inserted = 0
|
print(f"[enqueue] daemon démarré, poll toutes les {POLL_INTERVAL}s")
|
||||||
country_fixed = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
while True:
|
||||||
"SELECT ma_id, country, location_text FROM bands "
|
# Chercher un job en attente
|
||||||
"WHERE location_text IS NOT NULL AND location_text <> '' "
|
cur.execute(
|
||||||
"ORDER BY ma_id ASC LIMIT %s",
|
"""
|
||||||
(batch,),
|
SELECT id FROM job_triggers
|
||||||
)
|
WHERE job_type = 'geocoder_enqueue' AND status = 'pending'
|
||||||
bands = cur.fetchall()
|
ORDER BY created_at ASC
|
||||||
print(f"[enqueue] {len(bands)} bands à parser")
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
for ma_id, country, location_text in bands:
|
if not row:
|
||||||
steps = parse_location_text(location_text, country)
|
time.sleep(POLL_INTERVAL)
|
||||||
if not steps:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for step in steps:
|
job_id = row[0]
|
||||||
try:
|
cur.execute(
|
||||||
cur.execute(
|
"UPDATE job_triggers SET status='running', started_at=now() WHERE id=%s",
|
||||||
"""
|
(job_id,),
|
||||||
INSERT INTO band_locations
|
)
|
||||||
(ma_id, step_order, step_label, location_raw,
|
cur.execute(
|
||||||
is_country_only, geocode_status, geocode_next_at)
|
"INSERT INTO crawl_log (level, message) VALUES ('info', %s)",
|
||||||
VALUES (%s, %s, %s, %s, %s, 'queued', now())
|
(f"[geocoder_enqueue] job #{job_id} démarré",),
|
||||||
ON CONFLICT (ma_id, step_order, location_raw) DO NOTHING
|
)
|
||||||
""",
|
print(f"[enqueue] job #{job_id} démarré")
|
||||||
(ma_id, step['step_order'], step['step_label'],
|
|
||||||
step['location_raw'], step['is_country_only']),
|
|
||||||
)
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
inserted += 1
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[enqueue] WARN insert ma_id={ma_id}: {exc}")
|
|
||||||
|
|
||||||
# Résoudre immédiatement les entrées pays-seulement
|
try:
|
||||||
cur.execute(
|
inserted, country_fixed, skipped = run_enqueue(cur)
|
||||||
"""
|
summary = (
|
||||||
SELECT bl.id, bl.location_raw, b.country
|
f"[geocoder_enqueue] job #{job_id} terminé — "
|
||||||
FROM band_locations bl
|
f"inserted={inserted} country_fixed={country_fixed} skipped={skipped}"
|
||||||
JOIN bands b ON b.ma_id = bl.ma_id
|
|
||||||
WHERE bl.geocode_status = 'queued'
|
|
||||||
AND bl.is_country_only = TRUE
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
country_rows = cur.fetchall()
|
|
||||||
|
|
||||||
for loc_id, location_raw, band_country in country_rows:
|
|
||||||
iso2 = resolve_iso2(location_raw, band_country)
|
|
||||||
coords = country_centroid(iso2) if iso2 else None
|
|
||||||
if coords:
|
|
||||||
lat, lon = coords
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE band_locations
|
|
||||||
SET lat=%s, lon=%s,
|
|
||||||
geocode_status='country_only',
|
|
||||||
geocode_provider='country_centroid',
|
|
||||||
geocode_confidence=0.1,
|
|
||||||
geocode_query=%s,
|
|
||||||
updated_at=now()
|
|
||||||
WHERE id=%s
|
|
||||||
""",
|
|
||||||
(lat, lon, iso2, loc_id),
|
|
||||||
)
|
)
|
||||||
country_fixed += 1
|
cur.execute(
|
||||||
else:
|
"UPDATE job_triggers SET status='done', finished_at=now() WHERE id=%s",
|
||||||
print(f"[enqueue] no centroid id={loc_id} raw='{location_raw}'")
|
(job_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO crawl_log (level, message) VALUES ('info', %s)",
|
||||||
|
(summary,),
|
||||||
|
)
|
||||||
|
print(f"[enqueue] {summary}")
|
||||||
|
except Exception as exc:
|
||||||
|
err = str(exc)[:400]
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE job_triggers SET status='error', finished_at=now(), error=%s WHERE id=%s",
|
||||||
|
(err, job_id),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO crawl_log (level, message) VALUES ('error', %s)",
|
||||||
|
(f"[geocoder_enqueue] job #{job_id} erreur: {err}",),
|
||||||
|
)
|
||||||
|
print(f"[enqueue] job #{job_id} erreur: {err}")
|
||||||
|
|
||||||
print(
|
|
||||||
f"[enqueue] done — inserted={inserted} "
|
|
||||||
f"country_fixed={country_fixed} skipped={skipped}"
|
|
||||||
)
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,13 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
GEOCODE_ENQUEUE_BATCH: "100000"
|
GEOCODE_ENQUEUE_BATCH: "100000"
|
||||||
|
GEOCODE_ENQUEUE_POLL: "15"
|
||||||
command: ["python", "src/enqueue.py"]
|
command: ["python", "src/enqueue.py"]
|
||||||
networks:
|
networks:
|
||||||
- coolify
|
- coolify
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=false
|
- traefik.enable=false
|
||||||
restart: "no"
|
restart: unless-stopped
|
||||||
|
|
||||||
geocoder-worker:
|
geocoder-worker:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,13 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
GEOCODE_ENQUEUE_BATCH: "100000"
|
GEOCODE_ENQUEUE_BATCH: "100000"
|
||||||
|
GEOCODE_ENQUEUE_POLL: "15"
|
||||||
command: ["python", "src/enqueue.py"]
|
command: ["python", "src/enqueue.py"]
|
||||||
networks:
|
networks:
|
||||||
- coolify
|
- coolify
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=false
|
- traefik.enable=false
|
||||||
restart: "no"
|
restart: unless-stopped
|
||||||
|
|
||||||
geocoder-worker:
|
geocoder-worker:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue