metalfrom.eu/apps/geocoder/src/enqueue.py
Nicolas Fryder f145188d1f fix(geocoder): band_locations.ma_id en BIGINT — débloque 62k bands
bands.ma_id est BIGINT (crawler génère jusqu'à ~3.5e9) mais band_locations.ma_id
avait été créé en INTEGER (max 2.1e9). Tout INSERT pour un band à ma_id élevé
échouait en "integer out of range", erreur avalée silencieusement par l'enqueue.
62 239 bands (sur 96 143 localisables) n'entraient donc jamais dans le pipeline,
d'où 0 llm_needed / 0 erreur trompeurs.

- migration 008: ma_id BIGINT (installs neuves)
- migration 009: ALTER COLUMN ma_id TYPE BIGINT (DB existante)
- enqueue.py: compte les inserts échoués (failed=) au lieu de les avaler
- worker.py: sync_bands_primary prend le lieu d'ORIGINE (step 0) et non la
  dernière localisation — garde les groupes européens en Europe
- admin app.js: coût LLM robuste au NaN (NUMERIC accepte la valeur spéciale NaN)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:47:30 +02:00

182 lines
6.1 KiB
Python

"""
enqueue.py — Daemon qui attend des job_triggers de type 'geocoder_enqueue'
et peuple band_locations depuis bands.location_text.
Déclenché depuis l'UI admin → POST /admin/api/job-triggers {job_type: "geocoder_enqueue"}
Tourne en continu (restart: unless-stopped), poll toutes les 15s.
"""
import os
import time
import psycopg2
from parser import (
parse_location_text,
country_centroid,
COUNTRY_NAME_TO_ISO2,
)
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:
t = location_raw.strip()
if len(t) == 2 and t.upper().isalpha():
return t.upper()
iso = COUNTRY_NAME_TO_ISO2.get(t.lower())
if iso:
return iso
if band_country:
return band_country.strip().upper()
return None
def run_enqueue(cur) -> tuple[int, int, int]:
"""Parse tous les bands et peuple band_locations. Retourne (inserted, country_fixed, skipped)."""
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 = failed = 0
err_samples: list[str] = []
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:
failed += 1
if len(err_samples) < 5:
err_samples.append(f"ma_id={ma_id}: {exc}")
print(f"[enqueue] WARN insert ma_id={ma_id}: {exc}")
if failed:
print(f"[enqueue] {failed} inserts échoués — échantillon: {err_samples}")
# 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, failed
def main():
dsn = os.environ["DATABASE_URL"]
conn = psycopg2.connect(dsn)
conn.autocommit = True
print(f"[enqueue] daemon démarré, poll toutes les {POLL_INTERVAL}s")
with conn.cursor() as cur:
while True:
# Chercher un job en attente
cur.execute(
"""
SELECT id FROM job_triggers
WHERE job_type = 'geocoder_enqueue' AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
"""
)
row = cur.fetchone()
if not row:
time.sleep(POLL_INTERVAL)
continue
job_id = row[0]
cur.execute(
"UPDATE job_triggers SET status='running', started_at=now() WHERE id=%s",
(job_id,),
)
cur.execute(
"INSERT INTO crawl_log (level, message) VALUES ('info', %s)",
(f"[geocoder_enqueue] job #{job_id} démarré",),
)
print(f"[enqueue] job #{job_id} démarré")
try:
inserted, country_fixed, skipped, failed = run_enqueue(cur)
summary = (
f"[geocoder_enqueue] job #{job_id} terminé — "
f"inserted={inserted} country_fixed={country_fixed} "
f"skipped={skipped} failed={failed}"
)
cur.execute(
"UPDATE job_triggers SET status='done', finished_at=now() WHERE id=%s",
(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}")
conn.close()
if __name__ == "__main__":
main()