build_fallback_queries cherchait le code pays comme SOUS-CHAÎNE du lieu :
country_code.upper() in location_raw.upper()
Or "DE" est contenu dans "DRESDEN", "FR" dans "FRESNES", "NO" dans "NOTODDEN",
"ES" dans "TORRES". Le pays était donc considéré comme déjà présent, et la
variante « ville, pays » se retrouvait reléguée APRÈS la ville nue.
Le worker s'arrête au PREMIER résultat fiable : il interrogeait donc Geoapify
sans aucun contexte pays, précisément sur les noms ambigus — il existe un
Dresden dans l'Ohio. Sur un échantillon de 25 villes européennes réelles, 11
partaient sans désambiguïsation.
Le code est désormais cherché comme mot entier. Le test existant n'attrapait
rien parce qu'il vérifiait la PRÉSENCE du pays quelque part dans la liste
(`any(...)`), jamais sa priorité : les nouveaux cas verrouillent l'ordre.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Les deux sites étaient servis par nginx sans aucun en-tête de sécurité : le
helmet de l'API ne couvre que les réponses JSON, pas les pages HTML et JS.
L'admin, qui déclenche des actions destructrices, reçoit une CSP stricte —
script-src 'self' est tenable, son app.js n'utilisant aucun handler inline. Le
site public reçoit une CSP plus permissive sur script/style/img, pour ne pas
casser la carte, mais verrouille object-src, base-uri et frame-ancestors.
pgAdmin est retiré des deux compose sur décision explicite : une interface
d'administration de base exposée sur Internet, pour un usage ponctuel.
- Images de base épinglées (nginx:alpine → nginx:1.27-alpine).
- Utilisateur non-root pour l'api, le crawler et le geocoder.
- Le geocoder ne copie plus que src/ au lieu de tout le contexte.
- .dockerignore ajouté au crawler : son COPY src embarquait __pycache__.
- Keepalive vers l'upstream API (proxy_http_version 1.1 + Connection "").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La clé Geoapify part en query string. Toute exception requests — timeout,
raise_for_status, ConnectionError — embarque l'URL complète, donc la clé. Ce
message était écrit tel quel dans band_locations.geocode_error, que la vue
Localisations de l'admin affiche. Les exceptions sont remplacées par une erreur
sans URL, avec un masquage en filet.
Le groq-worker ne rattrapait que RuntimeError. Or requests lève des exceptions
héritant d'OSError : un 500 Groq ou un timeout s'échappait de la boucle et
tuait le process, que restart: unless-stopped relançait pour remourir aussitôt.
Les trois daemons faisaient `SELECT … FOR UPDATE SKIP LOCKED` avec
autocommit = True. Chaque instruction étant sa propre transaction, le verrou
tombait immédiatement : la clause ne protégeait rien. Sans conséquence avec une
réplique unique, mais toute mise à l'échelle aurait produit des appels API
payants en double. La réservation tient désormais en un seul UPDATE … RETURNING.
Autres correctifs :
- Une ligne passée en 'processing' puis abandonnée (redeploy, OOM) y restait
pour toujours : la file ne sélectionne que 'queued' et aucune route admin ne
visait ce statut. Récupération au démarrage et pendant les temps morts.
- is_reliable() acceptait n'importe quel résultat pour un lieu « pays seul »,
y compris sans confiance. Or une telle ligne n'arrive au worker que si le
centroïde a échoué — précisément quand il ne faut pas faire confiance.
- resolve_iso2() renvoyait n'importe quel code à deux lettres, "XX" compris.
- Alias non standards fréquents chez MA résolus (UK, Great Britain, Holland,
Czechia). COUNTRY_NAMES étant dérivé par inversion, où le dernier libellé
gagne, 'NL' est explicitement réimposé à « Netherlands ».
- max_tokens Groq relevé de 80 à 120 : la réponse JSON était parfois tronquée
à la source, ce que le repli de parsing ne peut pas réparer.
- Arrêt propre sur SIGTERM/SIGINT et reconnexion DB de l'enqueue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUG — _parse_json perdait les réponses contenant un objet imbriqué
Le repli utilisé quand le modèle enrobe son JSON de prose s'appuyait sur
`\{[^}]+\}`, qui s'arrête au PREMIER `}`. Dès que la réponse contenait un objet
imbriqué (ex. {"city": "Oslo", "meta": {"confidence": 0.9}}), la capture était
tronquée donc invalide : _parse_json renvoyait None, le lieu comptait comme
« aucune ville trouvée », consommait un essai LLM et finissait par basculer en
'manual'. Aucune erreur levée — défaut silencieux, sur un appel facturé.
Le re.DOTALL passé en argument était de surcroît inopérant, `[^}]` matchant
déjà les retours à la ligne : signe que l'intention était `.`.
Corrigé en `\{.*\}` glouton, du premier `{` au dernier `}`.
TESTS (23) — helpers purs de groq_worker, jusqu'ici sans couverture
- _parse_json : JSON propre, enrobé de prose, imbriqué, multiligne, et tous
les cas inexploitables — qui doivent rendre None sans jamais lever, sous
peine d'interrompre le worker.
- _input_hash : c'est la clé du cache LLM, donc ce qui décide de rappeler Groq
ou non. Vérifié qu'elle normalise casse et espaces (sinon on repaie des
appels déjà faits) et qu'elle distingue modèle, lieu et pays.
- _cost et PRICING : un modèle absent de PRICING serait facturé au tarif de
repli sans qu'on le remarque, faussant le coût affiché dans l'admin.
- _apply_clean_query : appelée pour de vrai avec un curseur factice plutôt que
de réécrire la concaténation dans le test.
conftest reproduit le sys.path du conteneur (src/ ET racine de l'app) :
worker.py et groq_worker.py importent `parser` sans préfixe parce qu'ils sont
lancés en `python src/worker.py`.
Vérification écartée : `COUNTRY_NAMES.get(code, iso2)` retombe sur le code brut
pour un ISO2 inconnu (« Oslo, ZZ »). Mon test l'avait pris pour un bug ; c'est
volontaire — mieux vaut un code qu'aucun contexte pays. Documenté.
Deux tests tautologiques écrits puis supprimés avant commit : ils comparaient
deux f-strings identiques et ne vérifiaient rien.
Co-Authored-By: Claude <noreply@anthropic.com>
BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.
SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.
COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.
PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
centroïdes. Invariants — jamais d'exception, structure toujours complète,
aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
sans effet de bord ni perte d'éléments, clés de coordonnées.
max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».
Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.
Co-Authored-By: Claude <noreply@anthropic.com>
Deux angles morts fermés.
1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js)
Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête
syntaxiquement invalide passait tous les tests et n'échouait qu'en production —
c'est précisément ce qui s'était produit avec crawler_pending.
Chaque requête réellement émise par les 28 routes est désormais parsée avec la
grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH
et les casts par type de resolve-conflict. 48 tests, aucun conteneur.
Limite déclarée explicitement : la sémantique n'est pas validée, et deux
requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue
si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il
passe au vert à vide.
2. Supervision des services de fond (migration 015)
Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP
ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un
worker à court de quota Geoapify, restait muet — le seul symptôme était
l'absence de données nouvelles, qu'il fallait remarquer soi-même.
Chaque service écrit un battement de cœur horaire dans service_health :
- crawler : base, FlareSolverr joignable, dernier run terminé < 12 h
- geocoder : base, clé Geoapify présente, API joignable, progression < 6 h
Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL
(> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier
contrôle réussi le ferait sinon passer pour sain indéfiniment.
Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou
silencieux en alerte actionnable.
Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle
surveille. Toute exception devient un échec de sonde, l'écriture du résultat et
la journalisation échouent en silence. Un contrôle de santé qui fait tomber le
crawler serait pire que pas de contrôle.
Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3)
Co-Authored-By: Claude <noreply@anthropic.com>
Le dépôt n'avait aucun test, aucun linter, aucune vérification de types.
Outillage
- ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python
- tsc --checkJs sur l'API (pas de TypeScript, juste la vérification)
- Vitest : 401 tests JS ; pytest : 43 tests Python
- Tests de mutation (Stryker), deux profils : logique pure et API complète
- Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook,
c'est donc la seule porte de qualité avant la mise en ligne
- Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré)
Sécurité
- Injection SQL authentifiée dans resolve-conflict : `field` était interpolé
dans le SET sans allowlist
- timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401)
- setErrorHandler écrasait tous les 4xx en 500
- .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part
alors que leur absence casse toute connexion admin
Annulation réelle des crawl_run (migration 014)
- L'API posait status='error' sans que le crawler en sache rien : le process
continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur)
- Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler
écrit lui-même status='cancelled'
Cohérence géographique (migration 014)
- Le trigger 013 supprimait les band_locations sans purger le point dénormalisé
- L'édition admin de lat/lon n'atteignait jamais band_locations : la carte
ignorait la correction. Override step_order = -1, dans une transaction
Corrections
- limit/offset NaN → 500 au lieu de 400
- OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie)
- listen() sans catch, cast ::text en dur sur les colonnes numériques
- /admin/api/logs ne renvoyait pas sa pagination
- a11y : sélecteur de langue annoncé comme liste vide (role=option manquant)
Nettoyage
- apps/web/quizz-site supprimé (sans rapport avec le projet)
- Code mort : openModal(), LANG_NAMES, double import, variables inutilisées
- .dockerignore ajoutés ; node_modules racine n'était pas gitignoré
Co-Authored-By: Claude <noreply@anthropic.com>
Étape 0 de la refonte pipeline crawl/géocodage.
Auto-requeue du géocodage
- migration 013: trigger trg_bands_geocode_dirty (AFTER UPDATE OF location_text
ON bands) supprime les band_locations d'un band dès que sa localisation
change réellement (IS DISTINCT FROM) — plus de points fantômes après un
déménagement détecté par le crawler incrémental.
- enqueue.py: en plus du déclenchement manuel, un scan automatique tourne
toutes les ENQUEUE_AUTO_INTERVAL_MIN minutes (défaut 60) et rattrape les
bands rendus "dirty" par le trigger (idempotent, ON CONFLICT DO NOTHING —
pas de canal de notification supplémentaire nécessaire). Chaque exécution
(manuelle ou auto) crée une ligne crawl_run + logs rattachés, en miroir de
crawler/src/db.py, pour apparaître dans l'activité admin à venir.
Crawl complet en calcul glissant
- remplace CRAWLER_SCHED_FULL_DAY (jour fixe du mois) par
CRAWLER_FULL_CRAWL_INTERVAL_DAYS (défaut 60) basé sur le checkpoint
last_full_crawl_at — robuste au calendrier et aux redémarrages (vérifié
aussi au démarrage, pas seulement le tick quotidien 03:00 UTC).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
is_reliable exige strictement > MIN_CONFIDENCE : une confiance de 0.7 pile
(ou moins) n'est plus acceptée et part vers le LLM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Durcissement fiabilité (suite audit).
Confiance & granularité
- worker: un résultat n'est accepté ('done') que si confiance >= GEOCODE_MIN_CONFIDENCE
(0.7) ET granularité non-grossière (rejette country/state/county/region). Sinon
on continue les fallbacks, puis -> llm_needed.
- fix majeur: sur cache hit la confiance était écrite 0.5 en dur (97% des lignes
faussées). Elle est désormais lue depuis geocode_cache (recalculée du raw).
- migration 012: colonnes confidence+granularity sur geocode_cache (recalcul des
entrées Geoapify depuis le raw), geocode_granularity sur band_locations.
Dedup (7x moins de travail)
- fast-path: un band_location dont le (lieu,pays) est déjà 'done' copie le
résultat sans appel API. Index fonctionnel lower(location_raw).
Ancien pipeline retiré
- geocode_queue n'est plus lu nulle part (endpoints /geocoding/reset-errors et
/requeue-all supprimés, /live et /geocoding et Monitor basculés sur
band_locations, panneau admin "ancien pipeline" retiré).
Boutons reset (onglet Géocodage, zone dangereuse)
- POST /admin/api/locations/reset-all: remet tout en queue + efface coords
- POST /admin/api/geocode-cache/purge-nominatim: purge le cache Nominatim
Divers
- groq_worker: coût calculé par modèle (70B vs 8B) au lieu du tarif 70B fixe
- GEOCODE_MIN_CONFIDENCE ajouté aux deux compose
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
- 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>
Geocoder → Geoapify (bcb790b9007644d1a44ff2391118479c) :
- Remplace Nominatim par Geoapify dans apps/geocoder/src/worker.py
- Délai réduit à 0.22s (5 req/s vs 1 req/s Nominatim) → bien plus rapide
- Même logique de cache geocode_cache, même fallback backoff progressif
- Env vars : GEOAPIFY_API_KEY (obligatoire), GEOCODER_MIN_DELAY/JITTER
Fix crawler incremental_modified (Expecting value: line 1 column 1) :
- ma_http.get_json() retenait sans retry sur JSONDecodeError (corps vide =
session Chrome morte). Désormais : refresh session + retry, comme pour
les erreurs 403/429.
Admin dashboard :
- Bands : colonne Lieu, champ recherche lieu séparé (location_q), filtres
"Géocodé oui/non" (has_lat) et "Lieu vide/renseigné" (has_location)
- Queue : bouton "Annuler runs bloqués >30min" (POST /admin/api/crawl-runs/
cleanup), auto-refresh 15s, colonne Progression avec durée elapsed pour
les runs actifs
- Dashboard : toutes les listes pays/genre/statut sans limite (scroll interne)
- Progression live : crawler écrit les stats dans crawl_run toutes les 50
bands (update_crawl_run_progress), visible dans Queue en temps réel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>