- Migration 008 : tables band_locations (N steps × M villes par band) et llm_cache (évite les double-appels LLM par sha256) - parser.py : parse location_text en steps structurés, gère N/A/Unknown, villes multiples (Bergen / Oslo), hiérarchies admin, codes pays - enqueue.py rewrite : peuple band_locations depuis bands, résout les is_country_only avec centroïdes hardcodés (confidence=0.1) - worker.py rewrite : fallbacks progressifs Geoapify (plus spécifique → plus vague), sync bands.lat/lon depuis le step le plus récent, bascule en llm_needed après 3 échecs - groq_worker.py (nouveau) : Groq free tier JSON mode, llm_cache, rate-limit par modèle, backoff exponentiel, fallback 8B si 70B saturé - docker-compose : geocoder-enqueue (one-shot), groq-worker (continu), geocoder-worker devient unless-stopped - Admin API : /geocoding retourne stats band_locations + llm_cache ; nouvelles routes /locations/reset-errors /reset-llm /requeue-all - Admin UI : page Géocodage affiche les deux pipelines en parallèle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
9.8 KiB
Python
275 lines
9.8 KiB
Python
"""
|
|
worker.py — Géocodeur Geoapify pour band_locations.
|
|
|
|
Boucle infinie, traite geocode_status='queued' un par un avec SKIP LOCKED.
|
|
Stratégie progressive :
|
|
1. Vérifier geocode_cache (requête déjà faite → gratuit)
|
|
2. Essayer les requêtes fallback du parser (du plus précis au plus vague)
|
|
3. Après MAX_GEO_TRIES échecs → status='llm_needed' pour groq_worker
|
|
4. Succès → mise à jour band_locations + sync bands.lat/lon
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import random
|
|
import json
|
|
import requests
|
|
import psycopg2
|
|
|
|
from parser import build_fallback_queries
|
|
|
|
GEOAPIFY_API_KEY = os.environ.get("GEOAPIFY_API_KEY", "").strip()
|
|
GEOAPIFY_BASE = "https://api.geoapify.com/v1/geocode/search"
|
|
|
|
MIN_DELAY = float(os.environ.get("GEOCODER_MIN_DELAY", "0.22"))
|
|
JITTER = float(os.environ.get("GEOCODER_JITTER", "0.10"))
|
|
MAX_PER_RUN = int(os.environ.get("GEOCODE_MAX_PER_RUN", "100000"))
|
|
MAX_GEO_TRIES = int(os.environ.get("GEOCODE_MAX_GEO_TRIES", "3"))
|
|
|
|
_last_call = 0.0
|
|
|
|
|
|
def _polite_sleep():
|
|
global _last_call
|
|
elapsed = time.monotonic() - _last_call
|
|
wait = max(0.0, MIN_DELAY - elapsed) + random.uniform(0.0, JITTER)
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
_last_call = time.monotonic()
|
|
|
|
|
|
def geoapify_search(query: str) -> dict | None:
|
|
if not GEOAPIFY_API_KEY:
|
|
raise RuntimeError("GEOAPIFY_API_KEY not set")
|
|
params = {"text": query, "apiKey": GEOAPIFY_API_KEY, "limit": 1}
|
|
_polite_sleep()
|
|
r = requests.get(GEOAPIFY_BASE, params=params, timeout=30)
|
|
if r.status_code == 429:
|
|
raise RuntimeError("geoapify_throttle:429")
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
features = data.get("features") or []
|
|
if not features:
|
|
return None
|
|
props = features[0].get("properties", {})
|
|
coords = features[0].get("geometry", {}).get("coordinates", [])
|
|
if len(coords) < 2:
|
|
return None
|
|
rank = props.get("rank") or {}
|
|
confidence = rank.get("confidence", 0.5) if isinstance(rank, dict) else 0.5
|
|
return {
|
|
"lat": props.get("lat") or coords[1],
|
|
"lon": props.get("lon") or coords[0],
|
|
"confidence": confidence,
|
|
"raw": data,
|
|
}
|
|
|
|
|
|
def sync_bands_primary(cur, ma_id: int):
|
|
"""Met à jour bands.lat/lon avec le step le plus récent géocodé."""
|
|
cur.execute(
|
|
"""
|
|
SELECT lat, lon FROM band_locations
|
|
WHERE ma_id = %s
|
|
AND geocode_status IN ('done', 'country_only')
|
|
AND lat IS NOT NULL AND lon IS NOT NULL
|
|
ORDER BY step_order DESC, id DESC
|
|
LIMIT 1
|
|
""",
|
|
(ma_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return
|
|
lat, lon = row
|
|
cur.execute(
|
|
"""
|
|
UPDATE bands
|
|
SET lat=%s, lon=%s,
|
|
geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,
|
|
geocoded_at=now(),
|
|
geocode_provider='band_locations',
|
|
geocode_error=NULL,
|
|
geocode_error_at=NULL
|
|
WHERE ma_id=%s
|
|
""",
|
|
(lat, lon, lon, lat, ma_id),
|
|
)
|
|
|
|
|
|
def main():
|
|
dsn = os.environ["DATABASE_URL"]
|
|
conn = psycopg2.connect(dsn)
|
|
conn.autocommit = True
|
|
|
|
processed = 0
|
|
with conn.cursor() as cur:
|
|
while processed < MAX_PER_RUN:
|
|
cur.execute(
|
|
"""
|
|
SELECT bl.id, bl.ma_id, bl.location_raw,
|
|
bl.geocode_tries_geo, bl.geocode_query, b.country
|
|
FROM band_locations bl
|
|
JOIN bands b ON b.ma_id = bl.ma_id
|
|
WHERE bl.geocode_status = 'queued'
|
|
AND bl.geocode_next_at <= now()
|
|
ORDER BY bl.geocode_next_at ASC, bl.id ASC
|
|
LIMIT 1
|
|
FOR UPDATE OF bl SKIP LOCKED
|
|
"""
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
print("[worker] rien à traiter, attente 60s")
|
|
time.sleep(60)
|
|
continue
|
|
|
|
loc_id, ma_id, location_raw, tries, llm_query, country = row
|
|
|
|
cur.execute(
|
|
"UPDATE band_locations SET geocode_status='processing', updated_at=now() WHERE id=%s",
|
|
(loc_id,),
|
|
)
|
|
|
|
# Liste de requêtes à essayer : requête LLM en premier si disponible
|
|
fallbacks = build_fallback_queries(location_raw, country)
|
|
if llm_query and llm_query not in fallbacks:
|
|
queries = [llm_query] + fallbacks
|
|
else:
|
|
queries = fallbacks
|
|
|
|
# Déduplique en gardant l'ordre
|
|
seen: set[str] = set()
|
|
unique: list[str] = []
|
|
for q in queries:
|
|
if q not in seen:
|
|
seen.add(q)
|
|
unique.append(q)
|
|
|
|
success = False
|
|
rate_limit = False
|
|
used_query = None
|
|
lat = lon = None
|
|
confidence = 0.5
|
|
provider = 'geoapify'
|
|
last_err = None
|
|
|
|
for query in unique:
|
|
# Cache hit ?
|
|
cur.execute(
|
|
"SELECT lat, lon FROM geocode_cache WHERE query=%s",
|
|
(query,),
|
|
)
|
|
cached = cur.fetchone()
|
|
if cached and cached[0] is not None:
|
|
lat, lon = cached
|
|
used_query = query
|
|
provider = 'geoapify-cache'
|
|
success = True
|
|
print(f"[worker] id={loc_id} cache HIT '{query}'")
|
|
break
|
|
|
|
try:
|
|
res = geoapify_search(query)
|
|
if res:
|
|
lat = float(res["lat"])
|
|
lon = float(res["lon"])
|
|
confidence = float(res.get("confidence") or 0.5)
|
|
used_query = query
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO geocode_cache
|
|
(query, provider, lat, lon, geom, raw, updated_at)
|
|
VALUES (%s,'geoapify',%s,%s,
|
|
ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,
|
|
%s, now())
|
|
ON CONFLICT (query) DO UPDATE
|
|
SET lat=EXCLUDED.lat, lon=EXCLUDED.lon,
|
|
geom=EXCLUDED.geom, raw=EXCLUDED.raw,
|
|
provider=EXCLUDED.provider, updated_at=now()
|
|
""",
|
|
(query, lat, lon, lon, lat, json.dumps(res["raw"])),
|
|
)
|
|
success = True
|
|
print(f"[worker] id={loc_id} OK '{query}' lat={lat:.4f} lon={lon:.4f}")
|
|
break
|
|
else:
|
|
last_err = f"no_result:'{query}'"
|
|
except RuntimeError as exc:
|
|
if "429" in str(exc):
|
|
print("[worker] rate limit Geoapify, attente 60s")
|
|
cur.execute(
|
|
"""
|
|
UPDATE band_locations
|
|
SET geocode_status='queued',
|
|
geocode_next_at=now() + interval '60 seconds',
|
|
updated_at=now()
|
|
WHERE id=%s
|
|
""",
|
|
(loc_id,),
|
|
)
|
|
rate_limit = True
|
|
time.sleep(60)
|
|
break
|
|
last_err = str(exc)[:300]
|
|
except Exception as exc:
|
|
last_err = str(exc)[:300]
|
|
|
|
if rate_limit:
|
|
continue
|
|
|
|
if success:
|
|
cur.execute(
|
|
"""
|
|
UPDATE band_locations
|
|
SET lat=%s, lon=%s,
|
|
geocode_status='done',
|
|
geocode_query=%s,
|
|
geocode_provider=%s,
|
|
geocode_confidence=%s,
|
|
geocode_error=NULL,
|
|
updated_at=now()
|
|
WHERE id=%s
|
|
""",
|
|
(lat, lon, used_query, provider, confidence, loc_id),
|
|
)
|
|
sync_bands_primary(cur, ma_id)
|
|
else:
|
|
new_tries = tries + 1
|
|
if new_tries >= MAX_GEO_TRIES:
|
|
cur.execute(
|
|
"""
|
|
UPDATE band_locations
|
|
SET geocode_status='llm_needed',
|
|
geocode_tries_geo=%s,
|
|
geocode_error=%s,
|
|
updated_at=now()
|
|
WHERE id=%s
|
|
""",
|
|
(new_tries, last_err or "all_fallbacks_failed", loc_id),
|
|
)
|
|
print(f"[worker] id={loc_id} → llm_needed après {new_tries} essais")
|
|
else:
|
|
backoff = min(1440, 30 * (2 ** new_tries))
|
|
cur.execute(
|
|
"""
|
|
UPDATE band_locations
|
|
SET geocode_status='queued',
|
|
geocode_tries_geo=%s,
|
|
geocode_error=%s,
|
|
geocode_next_at=now() + (%s || ' minutes')::interval,
|
|
updated_at=now()
|
|
WHERE id=%s
|
|
""",
|
|
(new_tries, last_err, backoff, loc_id),
|
|
)
|
|
print(f"[worker] id={loc_id} retry {new_tries}/{MAX_GEO_TRIES} in {backoff}m: {last_err}")
|
|
|
|
processed += 1
|
|
|
|
conn.close()
|
|
print(f"[worker] terminé, processed={processed}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|