Replatformisation complète. SQLite → PostgreSQL. Polling → queue atomique. Traduction → pipeline éditorial multi-stage en 24 langues.
V5 est un système de traduction : il prend une page, l'envoie à un LLM, stocke le résultat dans SQLite. V8 est un système de génération éditoriale : il construit des contenus originaux structurés en 24 langues, avec QA automatique, review humaine, publication statique SEO-complète et traçabilité totale par stage.
Queue et workers en pure asyncio. PostgreSQL FOR UPDATE SKIP LOCKED. Aucun Redis, aucun RQ, aucun broker externe.
Supprimés explicitement : Redis, RQ, queue_manager.py, REDIS_URL, workers RQ, services systemd liés.
MinHash, datasketch, signatures approximatives et index LSH sont supprimés. SHA256 est déterministe et suffisant.
Un seul projet opéré complètement. Multi-projets possible dès le schéma, activé progressivement après stabilisation.
EN (source) + 23 langues cible. Transcréation culturelle, pas traduction. Localisation par pays, devise, formalité.
HTML statique + SEO complet. hreflang 24 langues, sitemap.xml, JSON-LD, maillage interne résolu.
provider_keys.calls_* remis à zéro à la migration. Décision 2026-03-07.
review_items et publication_records repartent de zéro. Le mapping V5 est archival uniquement. Décision 2026-03-07.
machine_health_samples : rétention de 7 jours. Décision 2026-03-07.
Chaque table v5 a une décision explicite : copie directe, scission, dissolution dans de nouvelles structures, ou archivage.
| Table v5 | Destination v8 | Statut | Note |
|---|---|---|---|
| providers | public.providers | Direct | Compteurs calls_* remis à zéro |
| provider_keys | public.provider_keys | Direct | Copie directe |
| project_languages | public.project_languages | Direct | + colonnes is_source, publication_order |
| languages | public.languages | Direct | Support applicatif hors cœur |
| queue_control | public.queue_control | Direct | paused: INT → BOOLEAN |
| ip_proxies | public.ip_proxies | Direct | Support optionnel |
| project_prompt_templates | public.project_prompt_templates | Rebuild | Rendre stage-aware, ajouter prompt_hash |
| Table v5 | Destination v8 | Statut | Note |
|---|---|---|---|
| projects | public.projects + public.project_settings | Scission | Identité / opérationnel séparés. _2_id et _3_id supprimés. |
| scanned_pages | public.pages + public.page_meta | Scission | Enrichissement colonnes, normalized_url, slug calculé |
| Table v5 | Sort | Statut | Règle |
|---|---|---|---|
| jobs | → pages (méta) + migration.v5_job_page_map + archive | Dissout | Ne pas migrer la queue live |
| translations | → generated_contents (si fichiers lisibles) + archive | Dissout | ETL Python requis, backfill phase 1 désactivé |
| translation_attempts | archive.translation_attempts_legacy | Archive | Optionnel : generation_job_attempts |
| job_events | archive.job_events_legacy | Archive | Optionnel : execution_events |
| deploy_runs | public.publication_runs (≠ publication_records) | Rebuild | Objets différents — ne pas fusionner |
| stats_hourly | archive + réécriture v8 stage-aware | Rebuild | Schéma trop différent pour migrer |
| Table v8 | Rôle |
|---|---|
| generation_jobs | Queue principale — 1 job = 1 page × 1 langue × 1 stage |
| generated_contents | Contenus produits par stage et par langue, avec SHA256 obligatoire |
| qa_results | Scores et verdicts QA — table séparée (pas un champ de generated_contents) |
| review_items | Validation humaine — créés automatiquement après QA passée |
| publication_records | Sorties statiques, dates de publication, published_sha256 |
| content_hashes | Empreintes SHA256 pour contrôle anti-duplication |
| page_meta | Métadonnées pages non absorbées par le cœur pages |
| project_settings | Champs opérationnels sortis de projects |
| provider_health | Circuit breaker DB partagé entre workers |
| machine_health_samples | Santé machine, rétention 7 jours |
V5 ne structurait pas le contenu — chaque page était traitée de façon isolée. V8 construit d'abord une arborescence éditoriale complète (L1 → L2 → L3) avant de générer le moindre article. Chaque niveau passe par une validation humaine (HITL) avant de déclencher le niveau suivant. C'est l'étape 1 du pipeline éditorial — tout le reste en dépend.
| Table | Rôle | Note |
|---|---|---|
| categories | Nœuds de l'arborescence L1/L2/L3. Champs : level, parent_id, pillar_quality, global_status, project_id | Nouvelle table — aucun équivalent en v5 |
| category_translations | Traductions des noms et slugs de catégories par langue | Nouvelle table |
| article_masters | Articles associés à une catégorie L3. Porte global_status, intent_type, target_word_count | Nouvelle table — Étape 2 |
| article_metas | Titres, slugs, métas SEO par article | Nouvelle table — produit du batch de 40 titres |
| Statut | Sens | Transition possible vers |
|---|---|---|
| hitl_pending | Catégorie générée, en attente de validation humaine | hitl_approved / hitl_rejected |
| hitl_approved | Validée manuellement — déclenche transcréation + jobs niveau suivant | — |
| hitl_rejected | Rejetée — ne génère aucun descendant | — |
| auto_approved | Approuvée automatiquement (sans HITL, mode batch L3) | — |
pillar_quality ≠ hitl_approved, aucun job descendant n'est créé.
C'est l'humain qui ouvre la vanne à chaque niveau.
Une seule requête LLM produit 40 titres pour une catégorie L3. Chaque titre est classifié
par intent_classifier.py (9 types : definition, how_to, comparison, list, review, faq_cluster, local, transactional, informational).
Les limites CANNIBALIZATION_LIMITS empêchent deux articles avec le même intent sur le même sujet L3.
target_word_count est dérivé de l'intent — jamais demandé au LLM.
V5 avait une seule étape : traduire. V8 a un pipeline éditorial complet en 8 stages, enchaînés automatiquement par le worker.
| Stage terminé | Jobs créés automatiquement |
|---|---|
| source_content done | 23 jobs transcréation (une par langue cible) |
| transcreation done | 1 job qa |
| qa passed | 1 job publication |
| qa manual_review | 1 review_item créé ou mis à jour |
V8 ne traduit pas — il transcréé. Chaque langue est adaptée culturellement via localization.py :
pays, devise, ville, niveau de formalité variable. Le module anti_detection.py injecte du jitter, des
personas (6 rôles), des angles (6), des banned words, des longueurs cibles par intent.
intent_classifier.py classe 9 types de contenu. article_templates.py propose 8 templates rédactionnels.
C'est le changement conceptuel le plus profond. V5 agrégeait langues et passes dans un même job. V8 rend chaque unité de travail irréductible.
| Statut | Sens | Retryable |
|---|---|---|
| pending | Éligible au claiming dès que available_at ≤ now() et dépendances satisfaites | — |
| running | Claimé — possède locked_by, locked_at, heartbeat_at | — |
| done | Terminé avec succès, effet attendu produit | — |
| partial | Partie utile produite, pas l'état final complet | — |
| blocked | Action humaine requise (source_404, config invalide). Ne pas retenter automatiquement. | Non |
| cancelled | Abandonné volontairement (page exclue, job obsolète) | Non |
error durable en v8. Les erreurs retryables (timeout, 429, 5xx) retournent en pending avec backoff. Les erreurs terminales deviennent blocked ou cancelled. Rompre avec ce principe corrompt la traçabilité.
SELECT id FROM generation_jobs WHERE status = 'pending' AND available_at <= now() ORDER BY priority DESC, available_at ASC, id ASC FOR UPDATE SKIP LOCKED LIMIT :batch_size;
SKIP LOCKED garantit qu'aucun worker ne réclame le même job qu'un autre. Pas de locking applicatif, pas de Redis — PostgreSQL gère la concurrence nativement.
async def worker_loop(): while True: jobs = await claim_jobs(batch_size, worker_id) if not jobs: await asyncio.sleep(idle_sleep) continue await asyncio.gather(*(run_job(job) for job in jobs)) async def run_job(job): start_heartbeat(job) try: context = await build_context(job) result = await execute_stage(job, context) await persist_result(job, result) await enqueue_next_jobs_if_needed(job, result) await mark_done(job, result) except RetryableError as exc: await reschedule_job(job, exc) # → pending + backoff except BlockingError as exc: await mark_blocked(job, exc) # → blocked except CancelledJob as exc: await mark_cancelled(job, exc) # → cancelled finally: stop_heartbeat(job)
| Tentative | Délai avant retry | Classe d'erreur |
|---|---|---|
| 1 | +1 min | RetryableError (timeout, 429, 5xx réseau) |
| 2 | +5 min | RetryableError |
| 3 | +15 min | RetryableError → puis blocked si max_retries atteint |
| — | Immédiat blocked | BlockingError (source_404, config invalide) |
V5 n'avait aucune QA structurée. V8 exécute 11 checks automatiques via qa_checker.py et lingua-py (remplace langdetect).
Résultat stocké dans la table qa_results — table séparée, jamais un champ de generated_contents.
| Score | Statut QA | Conséquence |
|---|---|---|
| 100 | passed | → job publication créé automatiquement |
| 80 – 99 | qa_warn | Passe avec avertissements — publication possible |
| < 80 | failed | → retry du job transcréation |
| — | manual_review | → review_item créé, attente décision humaine |
qa_results depuis v5. V5 n'a pas de source structurée équivalente. Créer les qa_results uniquement après ré-exécution QA sur des generated_contents importés ou régénérés.
8 étapes dans un ordre strict. L'étape 0 est bloquante : si des collisions d'URLs sont détectées, rien ne peut avancer.
forge_backfill_review.py et forge_backfill_publication.py ne sont pas lancés. Décision 2026-03-07 : review_items et publication_records repartent de zéro en v8.
Chaque fichier backend v5 a un sort explicite en v8. Certains sont réutilisés fortement, d'autres réécrits de zéro, d'autres découpés en sous-modules stage-aware.
| Module v5 | Sort v8 | Statut | Note |
|---|---|---|---|
| database.py | Réécriture complète | Rebuild | SQLite/sqlite3 → asyncpg. Pool min=5 max=30. Primitives queue : claim, heartbeat, mark_done, reschedule, blocked, cancelled, reap. |
| worker.py | Réécriture complète | Rebuild | Polling HTTP v5 supprimé. Claim direct DB. Boucle asyncio. Dispatch par stage. Heartbeat, watchdog, semaphores process. |
| ia_client.py | Réemploi fort | Réemploi | Réécrire en httpx async. Supprimer semaphores Redis. Round-Robin api_keys via DB. Injecter params aléatoires à chaque appel. |
| translator.py | Découper en stages/ | Dissout | Monolithe supprimé. Remplacé par : stages/source_fetch.py · stages/source_extract.py · stages/transcreation.py · stages/qa.py · stages/publication.py |
| deployer.py | → publisher.py | Rebuild | Pas un simple renommage. publisher.py v8 = vrai pipeline HTML statique, publication_records, hreflang ×24, sitemap, résolution IDs maillage interne. |
| prompt_templates.py | Rendre stage-aware | Rebuild | Remplacer step par stage. Prompts : transcreation.default, source_extract.default, source_content.default, qa.default, category_gen.default, article_titles.default. |
| config.py | Mise à jour | Rebuild | Supprimer REDIS_URL, WORKER_GLOBAL_CONCURRENCY, WORKER_PER_PROVIDER_LIMIT. Ajouter DATABASE_URL, WORKER_GLOBAL_SEM, WORKER_BATCH_SIZE, FORGE_QA_MIN_SCORE. |
| main.py | Recâblage lourd | Rebuild | Supprimer lifespan Redis. Nouvelles routes : catégories HITL, articles, queue par stage, stats par stage, review, vagues, multi-projets. |
| — | intent_classifier.py | Nouveau | 9 types d'intent. classify_intent(title) → str. Base du target_word_count et anti-cannibalisation. |
| — | anti_detection.py | Nouveau | 6 personas, 6 angles, banned words, longueurs par intent, distribution FAQ, paramètres API aléatoires. |
| — | article_templates.py | Nouveau | 8 templates rédactionnels A→H. pick_template() par catégorie L3. |
| — | qa_checker.py | Nouveau | 11 checks, lingua-py (remplace langdetect). INSERT qa_results table séparée. |
| — | anti_dup.py | Nouveau | SHA256 uniquement. MinHash et datasketch supprimés. |
| — | localization.py | Nouveau | Matrice 24 langues : pays, devise, ville, formalité (Vous/Tu, Sie/Du…). |
| — | wave_planner.py | Nouveau | 5 vagues, plafond journalier configurable. assign_wave(), get_articles_to_publish_today(). |
| — | internal_links.py | Nouveau | get_link_candidates(), resolve_internal_links(). Remplace (ID_845) → URL réelle au déploiement. |
| — | image_manager.py | Nouveau | Cache local Unsplash/Pexels. Résolution des placeholder-image.jpg à la publication. |
uclur_adapter.py et uclur_translator.py comme pilotes de queue. Les déplacer dans adapters/ — ils produisent des pages uniquement, jamais des jobs.
V5 ne gérait aucun maillage interne. V8 injecte des liens contextuels dans chaque article via internal_links.py et les résout au moment de la publication.
Les liens sont référencés dans les prompts comme identifiants opaques (ID_845) — jamais comme URLs directes —
pour éviter que le LLM invente des liens. La résolution URL réelle n'a lieu qu'à l'étape publisher.py.
| Colonne | Type | Note |
|---|---|---|
| source_master_id | BIGINT FK | Article source du lien |
| target_master_id | BIGINT FK | Article cible |
| project_id | BIGINT FK | Isolation multi-projets |
| created_at | TIMESTAMPTZ | — |
internal_links — elle est générée par le LLM dans le contenu et résolue dynamiquement à la publication. Cela évite de gérer des ancres obsolètes lors des mises à jour de titres.
V5 déployait tout dès que c'était prêt. V8 échelonne la publication via wave_planner.py en 5 vagues avec un plafond journalier configurable.
Objectif : éviter les pics de crawl, échelonner l'indexation Google, et maîtriser le débit de nouvelles URLs par projet.
| Fonction | Rôle |
|---|---|
| assign_wave(master_id, priority) | Affecte un article à une vague. INSERT publication_waves + mise à jour article_masters.global_status → wave_assigned |
| get_articles_to_publish_today(pool, project_id) | Retourne les articles éligibles à la publication du jour dans les limites du plafond journalier par vague |
| Statut | Sens |
|---|---|
| pending | Article créé, pas encore lancé en génération |
| generating | Étape 3 en cours (contenu EN) |
| generated | Contenu EN produit et QA passée |
| transcreating | Étape 4 en cours (×23 langues) |
| ready | 24 langues terminées, QA passée, prêt pour vague |
| wave_assigned | Affecté à une vague de publication |
| published | Publié sur au moins une langue |
| rejected | Rejeté en review humaine |
| Critère | Seuil requis |
|---|---|
| Taux mapping jobs v5 → pages v8 | ≥ 95% |
| content_sha256 non null | 100% |
| provider_health initialisé (tous providers) | 100% |
| project_prompt_templates migrés | Tous projets actifs |
| Worker 1 job end-to-end sans erreur critique | 0 erreur critique |
| Heartbeat fonctionnel (jobs pas reapés prématurément) | Oui |
| QA insère qa_results distincts | Oui |
| Publication HTML locale correcte (hreflang, IDs résolus) | Oui |
| is_urgent mapping cohérent documenté | Vérifié |
| Rollback staging testé — restauration DB | < 10 min |
# 1. Arrêter les workers v8 systemctl stop forge-worker.service forge-api.service # 2. Restaurer la DB v8 depuis backup psql "postgresql://forge_user:PASS@.../postgres" \ -c "DROP DATABASE forge;" psql "postgresql://forge_user:PASS@.../postgres" \ -c "CREATE DATABASE forge OWNER forge_user;" psql "postgresql://forge_user:PASS@.../forge" \ < backup_pre_cutover_YYYYMMDD_HHMM.sql # 3. Remettre v5 en service sqlite3 forge_v5.db "UPDATE queue_control SET paused=0 WHERE id=1" systemctl start forge-worker@1.service forge-worker@2.service # 4. Les tables archive.* restent intactes pour analyse post-mortem
translations comme table de queue dans v8.
pending ou error dans generation_jobs. Partir de pages uniquement.
content_sha256 à partir de métadonnées SQL. Lire le fichier.
pass_number, default_provider_2_id, default_provider_3_id.
error comme statut durable dans generation_jobs.
qa_results depuis v5 : pas de source structurée, recalculer après régénération.
deploy_runs avec publication_records — ce sont deux objets distincts.
review_items ou publication_records sans generated_content_id résolu.
stats_hourly v5 : archiver et repartir du schéma v8 stage-aware.
Une installation V8 est saine si la queue vit exclusivement en base, la QA et les stats sont visibles,
et le pipeline complet tourne sur 1 projet en 24 langues sans Redis ni RQ.
Vérifier : API répond sur /api/health, heartbeat actif toutes les 15–30s,
generated_contents.content_sha256 non null sur chaque ligne,
provider_health.circuit_state reste closed.