Document de référence technique

i18n v5 Forge v8

Replatformisation complète. SQLite → PostgreSQL. Polling → queue atomique. Traduction → pipeline éditorial multi-stage en 24 langues.

24 Langues cible
8 Stages de pipeline
11 Checks QA auto
0 Redis / RQ
01

Pourquoi cette migration

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.

⊖ v5 — i18n
  • SQLite sur disque, accès synchrone
  • Redis + RQ comme broker de queue
  • 1 job = 1 page + N langues agrégées
  • pass_number — plusieurs passes par job
  • Résultats : fichiers JSON segmentés sur disque
  • Pas de QA automatique structurée
  • langdetect pour détection de langue
  • MinHash + datasketch pour anti-duplication
  • Statut error durable dans la queue
  • providers _2 et _3 par défaut par projet
  • Stats par passes, non par stage
  • Aucun pipeline éditorial structuré
⊕ v8 — Forge
  • PostgreSQL 16, accès asyncpg + pool
  • Aucun broker — queue native SQL SKIP LOCKED
  • 1 job = 1 page × 1 langue × 1 stage (atomique)
  • Une seule passe par stage, jamais plus
  • generated_contents avec content_sha256 obligatoire
  • 11 checks QA automatiques via qa_results
  • lingua-py pour détection de langue
  • SHA256 uniquement pour anti-duplication
  • Pas d'error durable : pending (retry) ou blocked
  • Un seul default_provider_id par projet
  • Stats par stage / provider / langue / modèle
  • Pipeline 8 étapes : fetch → extract → outline → content → transcréation → QA → review → publication
02

Décisions d'architecture verrouillées

Ces décisions sont définitives. Tout document qui les contredit est considéré obsolète.
🔒

asyncio + SKIP LOCKED

Queue et workers en pure asyncio. PostgreSQL FOR UPDATE SKIP LOCKED. Aucun Redis, aucun RQ, aucun broker externe.

🗑️

Redis / RQ supprimés

Supprimés explicitement : Redis, RQ, queue_manager.py, REDIS_URL, workers RQ, services systemd liés.

🔐

SHA256 uniquement

MinHash, datasketch, signatures approximatives et index LSH sont supprimés. SHA256 est déterministe et suffisant.

📦

1 projet au lot 1

Un seul projet opéré complètement. Multi-projets possible dès le schéma, activé progressivement après stabilisation.

🌐

24 langues

EN (source) + 23 langues cible. Transcréation culturelle, pas traduction. Localisation par pays, devise, formalité.

📄

Publication statique

HTML statique + SEO complet. hreflang 24 langues, sitemap.xml, JSON-LD, maillage interne résolu.

🔄

Compteurs remis à zéro

provider_keys.calls_* remis à zéro à la migration. Décision 2026-03-07.

🚫

Pas de backfill review

review_items et publication_records repartent de zéro. Le mapping V5 est archival uniquement. Décision 2026-03-07.

📅

Rétention 7 jours

machine_health_samples : rétention de 7 jours. Décision 2026-03-07.

03

Matrice des tables v5 → v8

Chaque table v5 a une décision explicite : copie directe, scission, dissolution dans de nouvelles structures, ou archivage.

Ce qui survit quasi tel quel

Table v5 Destination v8 Statut Note
providerspublic.providersDirectCompteurs calls_* remis à zéro
provider_keyspublic.provider_keysDirectCopie directe
project_languagespublic.project_languagesDirect+ colonnes is_source, publication_order
languagespublic.languagesDirectSupport applicatif hors cœur
queue_controlpublic.queue_controlDirectpaused: INT → BOOLEAN
ip_proxiespublic.ip_proxiesDirectSupport optionnel
project_prompt_templatespublic.project_prompt_templatesRebuildRendre stage-aware, ajouter prompt_hash

Ce qui se mappe avec transformation

Table v5 Destination v8 Statut Note
projectspublic.projects + public.project_settingsScissionIdentité / opérationnel séparés. _2_id et _3_id supprimés.
scanned_pagespublic.pages + public.page_metaScissionEnrichissement colonnes, normalized_url, slug calculé

Ce qui se dissout

Table v5 Sort Statut Règle
jobs→ pages (méta) + migration.v5_job_page_map + archiveDissoutNe pas migrer la queue live
translations→ generated_contents (si fichiers lisibles) + archiveDissoutETL Python requis, backfill phase 1 désactivé
translation_attemptsarchive.translation_attempts_legacyArchiveOptionnel : generation_job_attempts
job_eventsarchive.job_events_legacyArchiveOptionnel : execution_events
deploy_runspublic.publication_runs (≠ publication_records)RebuildObjets différents — ne pas fusionner
stats_hourlyarchive + réécriture v8 stage-awareRebuildSchéma trop différent pour migrer

Tables nouvelles dans v8 (n'existent pas en v5)

Table v8Rôle
generation_jobsQueue principale — 1 job = 1 page × 1 langue × 1 stage
generated_contentsContenus produits par stage et par langue, avec SHA256 obligatoire
qa_resultsScores et verdicts QA — table séparée (pas un champ de generated_contents)
review_itemsValidation humaine — créés automatiquement après QA passée
publication_recordsSorties statiques, dates de publication, published_sha256
content_hashesEmpreintes SHA256 pour contrôle anti-duplication
page_metaMétadonnées pages non absorbées par le cœur pages
project_settingsChamps opérationnels sortis de projects
provider_healthCircuit breaker DB partagé entre workers
machine_health_samplesSanté machine, rétention 7 jours
ℹ️ Convention de schémas PostgreSQL : legacy_v5 (dump SQLite lecture seule) · public (schéma v8 cible) · migration (tables de travail temporaires) · archive (copies de sécurité tables v5 supprimées)
legacy_v5
Tables v5 importées en lecture seule depuis le dump SQLite
public
Schéma v8 cible — source de vérité en production
migration
Tables de travail : v5_job_page_map, helpers de normalisation
archive
Copies de sécurité des tables v5 qui disparaissent
04

Génération des catégories L1/L2/L3

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.

Hiérarchie éditoriale

L1 — Piliers
~25 catégories grandes thématiques. Générées en EN, validées en HITL, puis transcréées × 23 langues async. Déclenchent automatiquement les jobs L2.
L2 — Sous-catégories
~250 catégories groupées par L1. HITL par lot. Génération batch. Validation : doublons cross-L1 flaggés automatiquement.
L3 — Sujets d'articles
~5000 catégories. Validation bulk HITL avec outils auto : similarité > 70% = doublon signalé, hors-silo détecté, cannibalisation intent limitée.

Cycle HITL par niveau

① Génération EN
LLM → INSERT categories + category_translations (EN)
② HITL ✏️🗑️➕
pillar_quality → hitl_approved. Sans ça : rien ne part.
③ Transcréation ×23
asyncio.gather → 23 jobs de traduction culturelle
④ Niveau suivant
INSERT auto des jobs génération L(n+1) EN

Tables — catégories dans v8

TableRôleNote
categoriesNœuds de l'arborescence L1/L2/L3. Champs : level, parent_id, pillar_quality, global_status, project_idNouvelle table — aucun équivalent en v5
category_translationsTraductions des noms et slugs de catégories par langueNouvelle table
article_mastersArticles associés à une catégorie L3. Porte global_status, intent_type, target_word_countNouvelle table — Étape 2
article_metasTitres, slugs, métas SEO par articleNouvelle table — produit du batch de 40 titres

Statuts pillar_quality

StatutSensTransition possible vers
hitl_pendingCatégorie générée, en attente de validation humainehitl_approved / hitl_rejected
hitl_approvedValidée manuellement — déclenche transcréation + jobs niveau suivant
hitl_rejectedRejetée — ne génère aucun descendant
auto_approvedApprouvée automatiquement (sans HITL, mode batch L3)
⚠️ Rien ne passe en L2 sans validation L1. Le cycle HITL est un verrou intentionnel : tant que pillar_quality ≠ hitl_approved, aucun job descendant n'est créé. C'est l'humain qui ouvre la vanne à chaque niveau.

Étape 2 — Batch de titres et anti-cannibalisation

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.

05

Pipeline éditorial v8

V5 avait une seule étape : traduire. V8 a un pipeline éditorial complet en 8 stages, enchaînés automatiquement par le worker.

1source_fetch
2source_extract
3outline
4source_content
5transcréation
×23 langues
6QA auto
7review
humaine
8publication
statique

Enchaînement automatique des stages

Stage terminéJobs créés automatiquement
source_content done23 jobs transcréation (une par langue cible)
transcreation done1 job qa
qa passed1 job publication
qa manual_review1 review_item créé ou mis à jour

Transcréation vs Traduction

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.

06

Modèle de job : l'atomicité

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.

v5 — job agrégat
  • 1 page
  • N langues groupées
  • pass_number (plusieurs passes)
  • Statut error durable
  • Table translations séparée
  • deploy_status au niveau job global
v8 — job atomique
  • 1 page × 1 langue × 1 stage
  • Contrainte d'idempotence sur ce triplet
  • Une seule passe, jamais plus
  • Pas d'error durable : pending ou blocked
  • generated_contents rattaché au job
  • publication_records par contenu généré

Statuts de job v8

StatutSensRetryable
pendingÉligible au claiming dès que available_at ≤ now() et dépendances satisfaites
runningClaimé — possède locked_by, locked_at, heartbeat_at
doneTerminé avec succès, effet attendu produit
partialPartie utile produite, pas l'état final complet
blockedAction humaine requise (source_404, config invalide). Ne pas retenter automatiquement.Non
cancelledAbandonné volontairement (page exclue, job obsolète)Non
Pas d'état 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é.

Claiming SQL atomique

database.py — claim_pending_jobs() SQL
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.

07

Worker asyncio — cycle de vie

⊖ v5 — worker
  • Synchrone, sqlite3 bloquant
  • Redis/RQ comme dispatcher
  • Gestion d'erreur minimale
  • Pas de heartbeat
  • Scripts shell pour démarrage (start-worker.sh)
  • Relance via watchdog shell
⊕ v8 — worker
  • Pure asyncio, asyncpg non bloquant
  • Claim direct en PostgreSQL
  • Classification précise : RetryableError / BlockingError / CancelledJob
  • Heartbeat toutes les 15–30s, reap après 10 min
  • Service systemd forge-worker.service
  • Semaphore global de process + semaphores par provider

Boucle principale

worker.py — worker_loop() Python
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)

Politique de backoff

TentativeDélai avant retryClasse d'erreur
1+1 minRetryableError (timeout, 429, 5xx réseau)
2+5 minRetryableError
3+15 minRetryableError → puis blocked si max_retries atteint
Immédiat blockedBlockingError (source_404, config invalide)
08

QA automatique — 11 checks

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.

min_words (selon intent)
has_h2 — au moins un H2
has_internal_links
has_images
faq_valid (structure FAQ)
slug_valid
slug_length (limites)
meta_title_len
meta_desc_len
no_json_artifact
correct_language (lingua-py)

Scoring

ScoreStatut QAConséquence
100passed→ job publication créé automatiquement
80 – 99qa_warnPasse avec avertissements — publication possible
< 80failed→ retry du job transcréation
manual_review→ review_item créé, attente décision humaine
Ne jamais backfiller 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.
09

Séquence de migration SQL

8 étapes dans un ordre strict. L'étape 0 est bloquante : si des collisions d'URLs sont détectées, rien ne peut avancer.

00
precheck.sql
Détection des collisions d'URLs
Vérification des collisions normalized_url et path avant tout INSERT. Deux URLs qui ne diffèrent que par la query string ou un slash final casseraient UNIQUE (project_id, normalized_url). Dédoublonner avant de continuer.
⛔ Bloquant
A
Figer v5
Mettre la queue v5 en pause
UPDATE legacy_v5.queue_control SET paused = 1 WHERE id = 1. Aucun nouveau job v5 ne doit partir pendant la migration.
⛔ Avant tout
B
01_referentiels.sql
Référentiels — projects, langues, providers, prompts
Copie projects → public.projects + public.project_settings (scission). Langues, providers, clés provider. Prompts rendus stage-aware : prompt_type → prompt_name + stage + prompt_hash.
✓ Standard
C
02_pages.sql
Inventaire source — pages + page_meta
scanned_pages → public.pages avec normalized_url calculé (helper migration.normalize_url) et slug généré (migration.slugify_path). Statuts v5 mappés vers page_source_status. Métadonnées dans page_meta.
✓ Standard
D
03_job_page_map.sql
Rapprochement jobs v5 → pages v8
Deux passes : match par path, puis par normalized_url. Résultat dans migration.v5_job_page_map. Vérifier le taux de matching avant de continuer.
✓ Contrôle qualité
E
04_archive.sql
Archiver v5
INSERT INTO archive.jobs_legacy, archive.translations_legacy, archive.translation_attempts_legacy, archive.job_events_legacy, archive.deploy_runs_legacy, archive.stats_hourly_legacy depuis legacy_v5.*
✓ Standard
F
05_seed_generation_jobs.sql
Reconstruire la queue v8
Ne jamais importer la queue live v5. Deux variantes : seed complet (toutes pages non exclues) ou seed restreint (pages v5 done/partial/blocked uniquement — recommandé pour éviter de tout republier d'un coup).
✓ Règle absolue
G
Optionnel
Backfill contenus — forge_backfill_contents.py
Pour chaque translation v5 avec status=done et fichier output_path lisible : lire HTML, calculer SHA256, insérer dans generated_contents + content_hashes. Impossible en SQL pur (SHA256 exige lecture de fichier).
ℹ Optionnel — phase 1 désactivé
H
06_checks.sql
Vérifications post-migration
Comptage pages, taux de matching jobs, volume queue v8, unicité normalized_url. Aucune ligne en doublon tolérée dans pages(project_id, normalized_url).
✓ Obligatoire
⚠️ 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.
10

Impact sur les modules backend

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 v5Sort v8StatutNote
database.pyRéécriture complèteRebuildSQLite/sqlite3 → asyncpg. Pool min=5 max=30. Primitives queue : claim, heartbeat, mark_done, reschedule, blocked, cancelled, reap.
worker.pyRéécriture complèteRebuildPolling HTTP v5 supprimé. Claim direct DB. Boucle asyncio. Dispatch par stage. Heartbeat, watchdog, semaphores process.
ia_client.pyRéemploi fortRéemploiRéécrire en httpx async. Supprimer semaphores Redis. Round-Robin api_keys via DB. Injecter params aléatoires à chaque appel.
translator.pyDécouper en stages/DissoutMonolithe supprimé. Remplacé par : stages/source_fetch.py · stages/source_extract.py · stages/transcreation.py · stages/qa.py · stages/publication.py
deployer.py→ publisher.pyRebuildPas un simple renommage. publisher.py v8 = vrai pipeline HTML statique, publication_records, hreflang ×24, sitemap, résolution IDs maillage interne.
prompt_templates.pyRendre stage-awareRebuildRemplacer step par stage. Prompts : transcreation.default, source_extract.default, source_content.default, qa.default, category_gen.default, article_titles.default.
config.pyMise à jourRebuildSupprimer REDIS_URL, WORKER_GLOBAL_CONCURRENCY, WORKER_PER_PROVIDER_LIMIT. Ajouter DATABASE_URL, WORKER_GLOBAL_SEM, WORKER_BATCH_SIZE, FORGE_QA_MIN_SCORE.
main.pyRecâblage lourdRebuildSupprimer lifespan Redis. Nouvelles routes : catégories HITL, articles, queue par stage, stats par stage, review, vagues, multi-projets.
intent_classifier.pyNouveau9 types d'intent. classify_intent(title) → str. Base du target_word_count et anti-cannibalisation.
anti_detection.pyNouveau6 personas, 6 angles, banned words, longueurs par intent, distribution FAQ, paramètres API aléatoires.
article_templates.pyNouveau8 templates rédactionnels A→H. pick_template() par catégorie L3.
qa_checker.pyNouveau11 checks, lingua-py (remplace langdetect). INSERT qa_results table séparée.
anti_dup.pyNouveauSHA256 uniquement. MinHash et datasketch supprimés.
localization.pyNouveauMatrice 24 langues : pays, devise, ville, formalité (Vous/Tu, Sie/Du…).
wave_planner.pyNouveau5 vagues, plafond journalier configurable. assign_wave(), get_articles_to_publish_today().
internal_links.pyNouveauget_link_candidates(), resolve_internal_links(). Remplace (ID_845) → URL réelle au déploiement.
image_manager.pyNouveauCache local Unsplash/Pexels. Résolution des placeholder-image.jpg à la publication.
Ne pas garder uclur_adapter.py et uclur_translator.py comme pilotes de queue. Les déplacer dans adapters/ — ils produisent des pages uniquement, jamais des jobs.
11

Maillage interne

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.

Cycle de vie d'un lien interne

① Sélection candidats
get_link_candidates(master_id, n=6)
Priorité : même L3 → L2 → pool projet
② Contexte prompt
{ID_845: "Titre cible", ID_912: "..."}
Injecté dans INTERNAL_LINKS_DATA
③ LLM écrit
Le LLM place (ID_845) dans le Markdown. Jamais d'URL directe.
④ Stockage
save_links(source_id, target_ids)
→ table internal_links (relation seule, pas d'ancre)
⑤ Résolution pub
resolve_internal_links(html)
(ID_845) → <a href="/fr/l1/l2/article/">

Table internal_links

ColonneTypeNote
source_master_idBIGINT FKArticle source du lien
target_master_idBIGINT FKArticle cible
project_idBIGINT FKIsolation multi-projets
created_atTIMESTAMPTZ
ℹ️ L'ancre de texte n'est pas stockée dans 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.
12

Publication par vagues

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.

Les 5 vagues

Vague 1
Priorité haute
Articles pilotes
Plafond bas
Vague 2
L1/L2 complets
Montée en charge
Vague 3
Volume L3
Batch moyen
Vague 4
Long tail
Plein régime
Vague 5
Complétion
Résidus

Fonctions wave_planner.py

FonctionRô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

Statuts global_status de article_masters

StatutSens
pendingArticle créé, pas encore lancé en génération
generatingÉtape 3 en cours (contenu EN)
generatedContenu EN produit et QA passée
transcreatingÉtape 4 en cours (×23 langues)
ready24 langues terminées, QA passée, prêt pour vague
wave_assignedAffecté à une vague de publication
publishedPublié sur au moins une langue
rejectedRejeté en review humaine
13

Validation staging obligatoire

Ne jamais tester la migration directement en production. La procédure staging est un prérequis bloquant au cutover. Tous les critères du tableau de passage doivent être verts avant de toucher la prod.

Architecture staging

Staging (machine dédiée / VM)
  • PostgreSQL 16 : forge_staging
  • Données : copie dump v5 prod
  • 1–2 workers en mode limité (SEM=5, BATCH=2)
  • FastAPI port 8001
  • DEPLOY_METHOD=local_only
  • Clés API test à quota limité
  • FORGE_OUTPUT_PATH=/tmp/forge_staging_output
Production (intouchée pendant staging)
  • PostgreSQL 16 : forge
  • v5 en service jusqu'à cutover validé
  • WORKER_GLOBAL_SEM=150, BATCH=20
  • FastAPI port 8000
  • Clés API prod
  • Aucun test de migration ici

7 étapes de validation staging

S1
dry-run
Migration dry-run
forge_migrate.sh --dry-run. Vérifier : aucune erreur SQL, jobs_mapped ≥ 95%, fichiers output_path trouvés pour ≥ 80% des traductions done, nombre de generation_jobs cohérent.
✓ Sans écriture
S2
migration
Migration réelle sur staging
forge_migrate.sh --execute. Référentiels, pages, job_page_map, archive, seed generation_jobs. Sans --with-review ni --with-publication (décision 2026-03-07).
✓ Staging seulement
S3
checks SQL
Checks post-migration
COUNT pages, taux mapping (⛔ < 95%), jobs seedés par stage/status, content_sha256 IS NOT NULL (⛔ si > 0 manquants), is_urgent → priority mapping, prompts migrés, provider_health initialisé.
⛔ Bloquant si critères KO
S4
worker
1 worker en mode limité — 10 jobs
BATCH=2 SEM=2. Observer : pending → running → done, heartbeat toutes les 30–60s, qa_results insérés, content_sha256 non null, pas de doublon actif sur (project_id, page_id, language_code, stage), circuit_state reste closed.
✓ Observer 10–15 min
S5
is_urgent
Vérification is_urgent → priority
Vérifier si jobs.is_urgent = 1 existe dans v5. Si oui → ces jobs doivent avoir priority = 200 en v8 (vs 100 pour les normaux). Documenter le résultat avant de passer en prod.
⚠ Correction #12
S6
publication
Test publication locale (sans CDN)
publication.py --dry-run, output vers /tmp/forge_staging_output. Vérifier : HTML généré, balises hreflang ×24 présentes dans <head>, (ID_XXX) tous résolus, publication_records insérés.
✓ Jamais sur CDN prod
S7
rollback
Test de rollback — obligatoire et chronométré
Simuler un rollback complet : arrêt workers, restauration DB depuis backup, vérifier cohérence, remettre queue_control.paused = 0 dans SQLite v5. Durée totale ≤ 10 min. Si dépassé → revoir stratégie backup avant cutover prod.
⛔ Obligatoire

Critères de passage staging → production

CritèreSeuil requis
Taux mapping jobs v5 → pages v8≥ 95%
content_sha256 non null100%
provider_health initialisé (tous providers)100%
project_prompt_templates migrésTous projets actifs
Worker 1 job end-to-end sans erreur critique0 erreur critique
Heartbeat fonctionnel (jobs pas reapés prématurément)Oui
QA insère qa_results distinctsOui
Publication HTML locale correcte (hreflang, IDs résolus)Oui
is_urgent mapping cohérent documentéVérifié
Rollback staging testé — restauration DB< 10 min
14

Cutover production & Go / No-Go

⚠️ Prérequis absolu : staging validé (tous critères verts). Ne pas démarrer cette procédure si une seule case est rouge.

Séquence de cutover

1
Préparer
Arrêt v5 + sauvegardes
systemctl stop forge-worker@*.service. Aucun job running en v5. pg_dump forge > backup_pre_cutover_$(date).sql. Copie SQLite v5. Copie répertoire output_path v5.
⛔ Avant tout
2
is_urgent
Vérifier is_urgent sur la base prod
sqlite3 forge_v5.db "PRAGMA table_info(jobs);" | grep is_urgent. Documenter : présent ? nombre de jobs urgents ? → détermine si priority = 200 sera appliqué dans le seed.
⚠ Documenter
3
Pilote
Dry-run sur 1 projet pilote
forge_migrate.sh --project-code MON_PROJET --verbose (sans --execute). Même vérifications que staging. jobs_mapped ≥ 95%, fichiers trouvés, seed cohérent, prompts corrects.
✓ Double confirmation
4
Migration
Migration réelle — projet pilote
forge_migrate.sh --execute --verbose. Sans --with-review ni --with-publication. Contrôles SQL immédiats : pages count, pct_mapped (⛔ < 95%), missing_sha256 (⛔ > 0), provider_health, prompts.
✓ 1 projet d'abord
5
Démarrage
Démarrer v8 progressivement
1 worker d'abord. Observer 15 min : pending → running → done, heartbeat actif, qa_results insérés, circuit_state closed, pas d'accumulation running bloqués. Puis 2 workers → 3 → charge nominale.
✓ Montée progressive
6
Validation
Validation fonctionnelle manuelle
Quelques pages/articles représentatifs : contenu extrait correct, transcréation sans JSON artifact, langue correcte, slug/path/URL publication corrects, review_items créés après QA, hreflang présent, sitemap à jour.
✓ Spot checks
7
Généraliser
Autres projets + charge nominale
Migrer projet par projet. Activer tous les workers. Surveiller le premier batch complet : stats_hourly, qa_results, provider_health. Vérifier l'absence de cannibalisation cross-projets.
✓ Une fois pilote stable

Go / No-Go

✓ Conditions de Go
  • v8 réellement runnable en staging
  • Seed des jobs fonctionne
  • Worker v8 traite les jobs de bout en bout
  • Un pilote produit un contenu exploitable
  • Publication minimale fonctionne
  • Rollback testé et chronométré (< 10 min)
  • Écarts critiques résolus ou acceptés explicitement
⛔ Conditions de No-Go
  • Pipeline instable en staging
  • Backlog d'écarts critiques non traité
  • Doublons métier non maîtrisés
  • Absence de rollback testé
  • Absence de visibilité sur les erreurs en prod
  • pct_mapped < 95% sans explication
  • Décision implicite sans validation formelle

Rollback production

Procédure rollback bash
# 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
Déclencheurs d'un rollback immédiat : mapping jobs/pages faux (pct_mapped < 80%), SHA256 null sur generated_contents en production, duplication massive de jobs, publication avec HTML incorrect ou URLs cassées.
15

Ce qu'il ne faut jamais faire

Migrer translations comme table de queue dans v8.
Importer les jobs v5 pending ou error dans generation_jobs. Partir de pages uniquement.
Calculer un content_sha256 à partir de métadonnées SQL. Lire le fichier.
Réintroduire pass_number, default_provider_2_id, default_provider_3_id.
Utiliser error comme statut durable dans generation_jobs.
Backfiller qa_results depuis v5 : pas de source structurée, recalculer après régénération.
Fusionner deploy_runs avec publication_records — ce sont deux objets distincts.
Backfiller review_items ou publication_records sans generated_content_id résolu.
Recalculer stats_hourly v5 : archiver et repartir du schéma v8 stage-aware.
Réintroduire Redis, RQ, MinHash, LSH, datasketch, langdetect, REDIS_URL, WORKER_GLOBAL_CONCURRENCY, WORKER_PER_PROVIDER_LIMIT.

Définition d'une installation v8 saine

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.