Aller au contenu

09 — Plan d'implémentation du POC Serpe

Traduit le doc 08 (vision consolidée) en plan d'exécution : ce qu'on code, dans quel ordre, avec quels fichiers, et ce qu'il faut débloquer avant. Ce document ne remet pas en cause le doc 08 — il le détaille côté implémentation.

0. État des lieux au 21/07/2026 (point de départ réel)

Trois chantiers existent en parallèle, sur trois branches, et ne sont pas encore fusionnés :

Branche Contenu État
feat/vertical-slice-memoire scripts/vertical-slice-memoire.ts : cadre → 13 blocs « Moyens humains » → fusion Gemini/Vertex → .docx Chaîne complète et prouvée sauf l'appel Vertex : 403 billing not enabled sur kayro-502614
feat/refonte-mockup (courante) app/pages/ao/[id]/rediger.vue (éditeur cadre-piloté) + app/pages/observabilite.vue (Module D), via useRedactionMemoire.ts / useObservabilite.ts UI posée, données 100 % mockées, aucun backend
feat/scaffold-poc (base) Schéma Supabase (0001-0004), auth, kanban, provider Gemini (server/utils/generation/providers/gemini.ts) Socle fonctionnel, jamais branché aux deux autres

Le travail principal du plan est donc de convergence : débloquer Vertex, généraliser le vertical slice, puis rebrancher le mockup UI sur le pipeline réel — pas de partir de zéro.

1. Vue d'ensemble

1.1 Schéma cible (doc 08 §3)

DCE reçu
  ├─► CADRE de mémoire ──► parseRubriques() ──► [{label, points, theme}]
  │      (paragraphes,        (src/cadre.js)         │  37% des cadres ont un barème
  │       pas styles Word)                           │  43% des rubriques : theme=null
  └─► RC / CCTP / DQE ──► contexte (acheteur, lieu, lots, contraintes)
   blocs.jsonl ──► sélection par THÈME ──► budget de mots ∝ POINTS
   (1 776 blocs, 13 thèmes, KV/bundle)                │
                         POST /api/generation/rubrique  (Nitro, gemini.ts)
                         réécriture LLM : fondre les blocs en prose,
                         adaptée au client/chantier, sourceLien conservé
                              assemblage .docx (lib `docx`, mise en forme Serpe)

1.2 Deux jalons, pas un seul

Le POC répond à deux questions distinctes, qu'il ne faut jamais mélanger dans la communication à Serpe :

  • Jalon « conforme » (atteignable maintenant) : produire un mémoire complet qui couvre toutes les rubriques du cadre client, avec un texte rédigé et traçable — la matière vient du corpus RAG (blocs.jsonl) et de la bibliothèque canonique. C'est la promesse du plan ci-dessous, étapes 1 à 6.
  • Jalon « compétitif » (bloqué par une donnée externe) : apprendre des affaires gagnées pour orienter la rédaction vers ce qui fait gagner, pas seulement ce qui a été écrit. Bloqué tant que l'export Zoho win/loss (code affaire → issue) n'est pas obtenu — étape 7, angle mort #1 (§7).

Ne jamais présenter le jalon 1 comme si l'app « apprenait à gagner » : sur les ~1 500 mémoires du corpus, l'issue n'est connue que pour 8 gagnés identifiables contre 1 382 perdus/inconnus.

2. Prérequis Vertex AI — à débloquer en premier

C'est le bloqueur unique qui empêche de valider ou d'invalider le vertical slice. Rien d'autre dans le plan n'a de sens tant que ce point n'est pas réglé.

2.1 Actions console GCP (projet kayro-502614)

  1. Activer la facturation sur le projet kayro-502614 (Console GCP → Facturation → Associer un compte de facturation). C'est la cause du 403 actuel (billing not enabled) — confirmé par le message d'erreur du vertical slice.
  2. Activer l'API Vertex AI (aiplatform.googleapis.com) si ce n'est pas déjà fait suite à l'activation de la facturation :
    gcloud services enable aiplatform.googleapis.com --project=kayro-502614
    
  3. Vérifier le rôle du service account utilisé par l'app (celui référencé par GOOGLE_APPLICATION_CREDENTIALS=./service-account.json en local, et par GOOGLE_SERVICE_ACCOUNT_JSON sur Cloudflare) : il doit porter le rôle roles/aiplatform.user (« Vertex AI User ») sur le projet, a minima.
    gcloud projects get-iam-policy kayro-502614 \
      --flatten="bindings[].members" \
      --filter="bindings.members:serviceAccount:<SA_EMAIL>" \
      --format="table(bindings.role)"
    
  4. Vérifier l'accès au modèle gemini-2.5-flash en europe-west4 — tous les modèles Gemini ne sont pas garantis dans toutes les régions. Test direct après activation :
    set -a; source .env; set +a
    npx tsx scripts/vertical-slice-memoire.ts   # branche feat/vertical-slice-memoire
    
    Un 403 persistant après facturation + API activées indique un problème de modèle/région (essayer us-central1 en repli, ou vérifier l'allowlist du modèle sur le projet via Model Garden).
  5. Quotas : le tiers gratuit/standard Vertex a des quotas par minute (requêtes et tokens) qui suffisent largement pour un POC (quelques mémoires par jour) mais peuvent limiter un test de charge sur 12 thèmes en boucle serrée — vérifier IAM & Admin → Quotas filtré sur aiplatform.googleapis.com si des 429 apparaissent pendant la généralisation (étape 1 ci-dessous).

2.2 Ordre de grandeur de coût

Un mémoire complet couvre ~10 à 15 rubriques (cadre typique). Pour chaque rubrique, l'appel generateContent reçoit : - la matière (blocs sélectionnés, plafonnée à INPUT_WORD_CAP ≈ 2 600 mots côté vertical slice, soit ~3 500 à 4 500 tokens en entrée selon la rubrique) ; - le prompt système/consignes (~200-400 tokens) ; - une sortie bornée par le budget de mots alloué (budgetMots ∝ points), typiquement ~700-1 500 tokens en sortie (maxOutputTokens: 4096 en plafond dur dans le code actuel).

Donc, par mémoire : 10-15 appels × (~4-5k tokens entrée + ~1k tokens sortie) ≈ 50-75k tokens entrée + 10-15k tokens sortie. Avec la tarification Gemini 2.5 Flash (tokens texte, à vérifier au tarif courant sur la page pricing Vertex AI au moment de l'implémentation — l'ordre de grandeur est de quelques centimes à ~0,10-0,20 € par mémoire complet, très loin d'être un poste de coût significatif face au temps chargé d'études économisé). Pas d'optimisation de coût nécessaire au stade POC ; à surveiller si le volume de mémoires générés par mois devient important (facturation dashboard GCP, budget alerte à poser dès l'activation de la facturation).

2.3 Local (fichier) vs Workers (JSON inline) — déjà géré

server/utils/generation/providers/gemini.ts gère déjà les deux chemins : - Local/Node : GoogleAuth sans credentials explicite → détection automatique via GOOGLE_APPLICATION_CREDENTIALS (fichier service-account.json, gitignoré) ; - Cloudflare Workers : GOOGLE_SERVICE_ACCOUNT_JSON (le contenu JSON du service account, inline, car pas de filesystem sur Workers) — variable à définir dans le dashboard Cloudflare (mode « variables plaintext par environnement », cf. commits récents sur wrangler.toml). - google-auth-library est importée dynamiquement dans generateSection (commentaire en tête de fichier) car son import statique casse le boot du Worker (util.inherits, streams incompatibles). Ne pas repasser cet import en statique lors de la généralisation.

Aucune action de code n'est nécessaire ici — seulement renseigner GOOGLE_SERVICE_ACCOUNT_JSON dans le dashboard Cloudflare Pages une fois que le service account a le bon rôle IAM (§2.1).

3. Étapes d'implémentation

Étape 1 — Généraliser le vertical slice aux 12 thèmes

Objectif : passer de « ça marche sur un thème choisi à la main » à « ça marche sur n'importe quel cadre, tous thèmes confondus ». Livrable : scripts/vertical-slice-memoire.ts (ou son successeur) capable de traiter un cadre entier (toutes ses rubriques) et de produire un .docx multi-sections. Dépend de : §2 débloqué (sinon on ne peut rien valider).

Tâches concrètes : 1. Merger/rebaser feat/vertical-slice-memoire une fois Vertex débloqué ; relancer npx tsx scripts/vertical-slice-memoire.ts et confirmer une vraie génération (pas le repli concaténation). 2. Généraliser loadCadreRubrique()loadCadreRubriques() : au lieu de chercher une seule rubrique par regex (RUBRIQUE_MATCH), reprendre src/cadre.js (audit) pour parser toutes les rubriques du cadre avec leur libellé + points, puis mapper chaque rubrique à un thème parmi les 13 (THEMES déjà listés dans app/composables/useRedactionMemoire.ts). 3. Généraliser selectBlocs(theme) : paramétrer par thème au lieu de la constante THEME = 'Moyens humains / personnel'. 4. Le budget de mots par rubrique doit être calculé (points / totalPoints * budgetMots), pas la constante budgetMots = 900 codée en dur — c'est déjà la logique documentée en doc 08 §3 et déjà implémentée côté mockup (budgetMots() dans rediger.vue) : réutiliser la même formule. 5. Boucler l'appel fusionGemini() sur chaque rubrique, avec gestion des rubriques theme: null (43 % des rubriques ne se rattachent à aucun thème aujourd'hui, cf. §7) : soit laisser en a_faire explicite pour rédaction manuelle, soit tenter un thème par défaut si le libellé matche un mot-clé. 6. Étendre ecrireDocx() pour assembler toutes les sections dans l'ordre du cadre (ordre de Rubrique), pas une seule. 7. Critère de succès explicite (doc 08 §7) : comparer le résultat à une page blanche — le juger avec un chargé d'études réel avant de généraliser plus loin.

Étape 2 — Ingestion : blocs.jsonl → Supabase/KV

Objectif : rendre le corpus interrogeable depuis l'app (pas seulement un fichier lu par un script CLI). Livrable : table(s) Supabase peuplées + (option) KV Cloudflare pour un accès bas-latence en prod. Dépend de : étape 1 validée.

Tâches concrètes : 1. Migration 0005_blocs_corpus.sql : créer blocs alignée sur le schéma réel de blocs.jsonl (doc 08 §6) — theme text, titre text, niveau text, texte text, mots int, annee int, resultat text check (resultat in ('PERDU','INCONNU','GAGNE','SANS_SUITE')), agence text, source_id text, source_nom text, source_lien text, canonique boolean default false (flag qui distingue corpus RAG importé vs bibliothèque canonique curée, au lieu de deux tables séparées — plus simple à filtrer et fait consensus avec doc 08 §6 qui laisse le choix). 2. Script d'ingestion scripts/ingest-blocs.ts : lit analyse_out/blocs.jsonl ligne à ligne (JSONL), valide le schéma, upsert par source_id (idempotent), insère en batchs de ~500 lignes via supabase-js (service role key, script exécuté localement, jamais depuis le Worker). 3. Pas de colonne embedding pour le POC (doc 08 §5.4) : filtrage par thème + filtres simples (année, agence, mots) suffit ; ne pas ajouter vector(512) sur blocs tant que le besoin de similarité fine n'est pas confirmé. 4. Option perf : dupliquer blocs.jsonl (3,1 Mo) dans un namespace KV Cloudflare (BLOCS_KV) au build/déploiement pour éviter une requête Supabase par génération de rubrique en prod — Supabase reste la source éditable (bibliothèque canonique), KV un cache de lecture pour le corpus. 5. Clé d'ancrage : code affaire (reference_affaire, format [Lettre][AA][NNNN]) partout où un bloc est rattaché à une affaire connue — c'est la clé de jointure avec Zoho plus tard (étape 7).

Étape 3 — Module A (analyse read-only) + Module D (observabilité)

Objectif : remplacer les données mockées par les vraies données de l'audit ; démontrer la valeur sans toucher à la génération. Livrable : /ao/[id] (onglet Analyse) et /observabilite alimentés en réel. Dépend de : étape 2 (ingestion) pour la partie corpus/blocs ; ingestion des métadonnées Drive (documents, appels_offres) pour la partie AO.

Tâches concrètes : 1. Module D en premier (le plus simple, purement lecture) : importer l'index.jsonl de l'audit (statistiques par agence, types de documents, variantes de nomenclature, dossiers vides, doublons probables) dans une table observabilite_snapshots (jsonb, un snapshot horodaté par run d'audit) ou directement en fichier statique servi par une route API — remplacer useObservabilite() (app/composables/useObservabilite.ts) par un appel useFetch('/api/observabilite/snapshot') qui lit cette source. Garder le bandeau « lecture seule » du mockup tel quel (observabilite.vue §0 doc 08 déjà respecté dans le mockup). 2. Module A : ingestion read-only des métadonnées Drive (documents, dossiers, code affaire, statut via reconnaissance par motifs 4 axes plutôt que statut_mapping en mécanisme principal — doc 08 §6) dans appels_offres / documents (schéma déjà présent dans 0001_init.sql). Extraction Gemini du DCE (carte d'identité, synthèse, contraintes) déjà prévue par server/utils/generation — brancher un endpoint dédié server/api/ao/[id]/analyse.get.ts qui déclenche/relit l'extraction. 3. Les deux modules sont parallélisables entre eux (doc 08 §7, ligne 4bis) mais partagent la même contrainte : aucune écriture sur le Drive, scope drive.readonly uniquement.

Étape 4 — Bibliothèque canonique (source de vérité)

Objectif : figer les blocs stables (présentation groupe, RSE, sécurité, certifications) en versions de référence, distinctes du corpus RAG bruyant. Livrable : blocs avec canonique = true pour un premier lot curé manuellement + blocs_versions pour l'historique daté. Dépend de : rien (parallélisable avec étapes 2-3, doc 08 §7 ligne 4).

Tâches concrètes : 1. Migration 0006_blocs_versions.sql : table blocs_versions(id, bloc_id → blocs, version text, contenu text, publie_le date, publie_par → users, actif boolean). 2. Identifier avec Serpe (ou provisoirement à la main) les blocs candidats : signal du doc 08 §5.2 — les thèmes « Présentation entreprise » (33 blocs) et « Références » (6 blocs) sont sous-représentés dans le corpus précisément parce qu'ils devraient venir d'une source unique curée, pas d'extraction automatique. 3. Créer en priorité un premier bloc canonique pour le thème « Continuité/astreinte/urgence », actuellement vide dans le corpus (angle mort #4, §7) — premier cas où le thème ne peut littéralement rien proposer sans bibliothèque canonique. 4. UI : réutiliser /bibliotheque/blocs du doc 06 §7 (maquette déjà écrite, pas encore implémentée) pour lister/gérer ces versions, avec le flag canonique filtrable.

Étape 5 — Module B : brancher le mockup sur le vrai pipeline

Objectif : faire de app/pages/ao/[id]/rediger.vue l'écran réel, pas une maquette à données mockées. Livrable : éditeur cadre-piloté fonctionnel de bout en bout (parse cadre → sélection blocs → génération par rubrique → assemblage .docx). Dépend de : étapes 1 à 4.

Tâches concrètes (dans l'ordre de câblage) : 1. Parse cadre côté serveur : server/api/ao/[id]/cadre.get.ts (ou .post.ts si upload à la volée) qui reprend la logique de loadCadreRubriques() généralisée à l'étape 1, appliquée au DCE de l'AO en cours (document déjà classé RC/cadre dans documents). Retourne un objet conforme au type CadreMemoire déjà défini dans app/composables/useRedactionMemoire.ts (aoReference, totalPoints, budgetMots, rubriques: Rubrique[]) — ne pas changer ce contrat, le composable et la page consomment déjà cette forme, il suffit de le remplacer par un vrai fetch. 2. Sélection de blocs : server/api/blocs/candidats.get.ts?theme=... remplace la fonction mock blocsParTheme() — interroge la table blocs (étape 2) filtrée par thème, avec canonique en tête de liste puis corpus RAG trié comme dans selectBlocs() (non-perdu > récent, dédoublonné). 3. Génération par rubrique : nouvel endpoint server/api/generation/rubrique.post.ts (détaillé §4 ci-dessous) qui remplace le setTimeout mocké de genererRubrique() dans rediger.vue par un vrai appel réseau ($fetch('/api/generation/rubrique', { method: 'POST', body: { rubriqueId, blocsSelectionnes, budgetMots } })). 4. Persistance : chaque génération/validation de rubrique écrit dans memoire_sections (étendue doc 07 §4 : type_section, statut, source_generation jsonb pour tracer prompt/tokens/blocs utilisés). 5. Assemblage .docx : bouton « Exporter .docx » de rediger.vue appelle un endpoint qui reprend ecrireDocx() du vertical slice, généralisé à toutes les sections validées d'un mémoire (§5 ci-dessous pour le choix Workers vs job). 6. Conserver l'affichage de traçabilité déjà présent dans la maquette (source du bloc, lien vers le mémoire d'origine) — c'est le facteur d'adoption n°1 selon l'audit (doc 08 §7).

Étape 6 — Références & visuels

Objectif : couvrir les rubriques notées par le cadre mais non-prose (références chantier, organigrammes, plans, photos, Gantt). Livrable : sélection de références structurées + insertion de visuels dans le .docx exporté. Dépend de : Module B (étape 5) posé.

Tâches concrètes : 1. Migration : table references_clients (doc 07 §5 : maitre_ouvrage, contact, objet, montant_ht, annee, type_prestation[], ao_source_id). Alimentée par les 234 fichiers REFERENCE_CHANTIER identifiés par l'audit — attestations/certificats scannés, pas de la prose (doc 08 §5.3), donc on ne les traite jamais comme des blocs de texte à réécrire. 2. Ne pas stocker les attestations elles-mêmes en base : lien vers le portail Attestations Légales existant (doc 04, rappelé doc 07 §8). 3. UI de sélection : type « sélection » (⊞) déjà prévu dans la maquette doc 06 §5 — filtrer par type de prestation/objet/montant/année, cocher, insérer en tableau de synthèse dans le .docx (pas de génération LLM ici). 4. Visuels : assets_visuels (déjà en base, 0001_init.sql) — brancher l'insertion dans ecrireDocx() généralisé (images/plans en pièce jointe de section, pas dans le corps réécrit par le LLM).

Étape 7 — Win/loss (export Zoho)

Objectif : passer du jalon « conforme » au jalon « compétitif ». Livrable : jointure code affaire → issue branchée sur blocs.resultat et appels_offres.resultat, utilisée pour prioriser les blocs gagnants dans la sélection. Dépend de : donnée Serpe (export Zoho, demande déjà partie côté analyse_doc/note-serpe-donnees-resultats.md selon l'audit) — hors de notre contrôle, à relancer/suivre.

Tâches concrètes une fois la donnée obtenue : 1. Script d'import scripts/ingest-winloss.ts : jointure sur reference_affaire, met à jour appels_offres.resultat et propage à blocs.resultat (les 1 382 mémoires actuellement PERDU|INCONNU peuvent être requalifiés pour ceux où la jointure existe). 2. Ajuster selectBlocs() : la priorité PERDU en dernier devient une vraie priorité GAGNE en tête, avec un signal explicite dans l'UI (« ce bloc vient d'une affaire gagnée »). 3. Uniquement à ce stade communiquer sur un mémoire « compétitif », pas avant.

4. Détails techniques — génération LLM

Où vit l'appel : nouvel endpoint Nitro server/api/generation/rubrique.post.ts, sœur de l'existant server/api/generation/test.post.ts (déjà en place, prouve l'auth + le provider). Réutilise getProvider() / server/utils/generation/index.ts sans modification de contrat — seul gemini.ts (provider) reste le point d'appel réseau Vertex.

Contrat de l'endpoint (aligné sur le type GenerateSectionParams déjà défini dans server/utils/generation/provider.ts : { prompt, context: string[], maxTokens? }) :

// server/api/generation/rubrique.post.ts
// body: { rubriqueLabel, points, contexteCadre, blocs: Bloc[], budgetMots }
// -> { texte, tokensIn, tokensOut, sourcesUtilisees: {blocId, sourceLien}[] }

Forme du prompt (reprend directement fusionGemini() du vertical slice, à factoriser dans server/utils/generation/prompts/rubrique.ts) : - Rôle : « chargé d'études SERPE rédigeant un mémoire technique de réponse à un appel d'offres public » — fixe, ne varie pas par thème. - Rubrique + attendu client : libellé exact de la rubrique du cadre + les points + le contexte immédiat (paragraphes suivants dans le cadre, qui portent souvent les sous-critères). - Blocs sources : chaque extrait numéroté avec métadonnée (année, agence, titre) pour que le modèle puisse structurer sans qu'on ait besoin de reparser la sortie pour retrouver la source — la correspondance extrait→section se fait en amont (sélection), pas en aval (parsing LLM). - Consignes non négociables (déjà dans le prompt actuel, à conserver telles quelles) : fondre plutôt que copier-coller, ne garder que les faits concrets présents dans les extraits, ne rien inventer (aucun chiffre ou nom absent des extraits), pas d'intro méta ni de conclusion commerciale. - Budget : ~${budgetMots} mots, calculé en amont ∝ points de la rubrique — c'est le prompt qui reçoit un nombre, pas le modèle qui décide de la longueur.

Streaming : pas nécessaire pour le POC — une génération par rubrique prend quelques secondes (maxOutputTokens: 4096), l'UI peut se contenter d'un spinner (nbGeneration déjà dans le mockup). À reconsidérer seulement si on enchaîne les 10-15 rubriques d'un mémoire en une seule action utilisateur (auquel cas un état de progression rubrique par rubrique suffit, pas du token streaming).

Gestion d'erreurs/coût : - Vertex indisponible (403 billing, quota, timeout) → repli identique au vertical slice : ne jamais planter la page, renvoyer un statut explicite et laisser l'utilisateur insérer les blocs bruts non réécrits (mieux qu'un échec silencieux) ; marquer la rubrique en a_faire avec un badge d'erreur plutôt que genere. - Logger tokensIn/tokensOut par génération dans memoire_sections.source_generation (doc 07 §4) — sert de base à un futur suivi de coût sans instrumentation supplémentaire. - Rate limiting côté endpoint si génération en masse (boucle des 10-15 rubriques) : séquentiel avec un petit délai plutôt que parallèle, pour rester sous les quotas Vertex par minute (§2.1 point 5).

Traçabilité sourceLien : chaque bloc utilisé garde sourceId/ sourceLien de bout en bout (règle d'or doc 08 §0.4) — l'endpoint retourne sourcesUtilisees en plus du texte, affiché sous la section générée (« Sources : 3 blocs · adaptée à CD16 / entretien EV » dans la maquette doc 08 §5.1) avec lien cliquable vers le mémoire d'origine.

5. Génération .docx

Librairie : docx (déjà utilisée et validée dans le vertical slice, package.json de la branche feat/vertical-slice-memoire : "docx": "^9.7.1") — à ajouter au package.json principal lors du merge de l'étape 1.

Respect de la mise en forme du cadre : le vertical slice actuel produit un document générique (titre POC + sections + bloc sources). Pour l'étape 5 (Module B), il faut évoluer vers un gabarit qui respecte le style Serpe : titre du mémoire, en-tête agence/AO, table des matières auto (déjà maquettée doc 06 §6 : « Sommaire auto »), numérotation des rubriques reprenant celle du cadre client (pas une numérotation Serpe interne).

Intégration des visuels : assets_visuels (organigrammes, fiches engins, Gantt) insérés en ImageRun (docx le supporte nativement) dans les sections concernées, pas fondus dans le texte réécrit par le LLM — cohérent avec l'étape 6.

Où l'exécuter : - Workers (recommandé pour le POC) : docx est une lib JS pure (pas de binaire natif), compatible nodejs_compat de Cloudflare Workers — le même environnement que la génération. Générer le .docx dans un endpoint server/api/memoires/[id]/export.post.ts et streamer le buffer en réponse. Avantage : pas d'infra supplémentaire, cohérent avec le déploiement Pages/ Workers actuel. - Job séparé : à envisager seulement si l'assemblage complet (10-15 sections + visuels + mise en page complexe) dépasse les limites CPU/mémoire d'un Worker (limite d'exécution Cloudflare) — non nécessaire au stade POC, à surveiller quand les visuels lourds (photos haute résolution) entrent en jeu.

6. Schéma & ingestion — migrations à ajouter

Migrations Supabase à créer, dans l'ordre de dépendance (suite à 0004_harden_functions.sql) :

Fichier Contenu Sert
0005_blocs_corpus.sql blocs (theme, titre, niveau, texte, mots, annee, resultat, agence, source_id, source_nom, source_lien, canonique bool) Étape 2 — ingestion corpus
0006_blocs_versions.sql blocs_versions (bloc_id, version, contenu, publie_le, publie_par, actif) Étape 4 — bibliothèque canonique
0007_cadres_rubriques.sql cadres (ao_id, source_document_id, total_points, budget_mots) + rubriques_cadre (cadre_id, label, points, theme, ordre, statut) Étape 5 — pilote de l'éditeur (remplace/complète criteres_ao du doc 07)
0008_references_clients.sql references_clients (maitre_ouvrage, contact, objet, montant_ht, annee, type_prestation[], ao_source_id) Étape 6
0009_memoire_sections_extend.sql ALTER memoire_sections : type_section, critere_id/rubrique_id, bloc_id, statut, source_generation jsonb, assets jsonb Étape 5
0010_appels_offres_extend.sql ALTER appels_offres : reference_affaire, type_prestation[], nature_marche, montant_estime_ht/ttc, ponderations jsonb, extraction_status Étape 3

statut_mapping existant : ne pas le supprimer, mais le documenter comme mécanisme de surcharge résiduel (doc 08 §6) — la reconnaissance par motifs sur les 4 axes de l'audit (docType/phase/statut/resultat) devient le mécanisme principal, statut_mapping ne sert qu'aux dossiers non reconnus par motif.

Pipeline d'ingestion read-only depuis le Drive (doc 07 §8) : métadonnées d'abord (nom, chemin, drive_file_id, code affaire extrait du nom de dossier/fichier), puis dans un second temps contenu → OCR/extraction → corpus_chunks + colonnes appels_offres. Toujours scope drive.readonly, jamais d'écriture — garde-fou à tester (test qui interdit files.create/update/delete/copy, doc 08 §0.1) avant tout script d'ingestion réel.

7. Risques & angles morts

  1. Vertex billing (bloqueur actuel, §2) — sans ça, aucune étape suivante ne peut être validée en conditions réelles (seul le repli sans LLM fonctionne, ce qui ne prouve rien sur la valeur produit).
  2. Formats legacy sur Workers : textutil (extraction .doc/.odt) n'existe pas sur Cloudflare Workers (c'est un binaire macOS). Pour les documents anciens en formats legacy, soit trouver une lib JS d'extraction (mammoth pour .docx, rien de fiable en JS pur pour .doc/.odt binaire), soit accepter d'écarter le legacy du périmètre d'ingestion automatique et le traiter en conversion manuelle ponctuelle.
  3. Win/loss manquant : 1 382 mémoires perdus/inconnus contre 8 gagnés identifiables — le jalon « compétitif » (étape 7) reste hors de portée tant que Serpe ne fournit pas l'export Zoho. Ne pas sous-estimer ce risque dans la communication : le POC ne peut pas apprendre à mieux gagner sans cette donnée, seulement produire un mémoire complet et conforme.
  4. 43 % des rubriques non rattachées à un thème : impact direct sur l'étape 1 (généralisation) et l'étape 5 (Module B) — une rubrique sans thème ne peut recevoir aucun bloc candidat automatiquement. Traiter comme un état a_faire explicite dans l'UI (déjà prévu dans la maquette : « ⚠ non rattachée », doc 08 §5.1), pas comme une erreur silencieuse. À terme, affiner le mapping rubrique→thème avec de vrais RC/cadres récents annotés (angle mort #2 du doc 08 §8).
  5. Thème « Continuité/astreinte/urgence » vide dans le corpus — premier cas concret où la bibliothèque canonique (étape 4) n'est pas un « nice to have » mais une nécessité : sans bloc canonique, ce thème ne peut littéralement rien proposer.
  6. Trois branches non convergées (§0) : risque de dérive si le mockup UI (feat/refonte-mockup) évolue sans que son contrat de données (CadreMemoire, Bloc, Rubrique dans useRedactionMemoire.ts) reste stable — c'est ce contrat qui sert de spec d'API pour l'étape 5. Le documenter comme gelé dès que le branchement réel commence, pour éviter d'avoir à resynchroniser UI et backend deux fois.

8. Jalons datables

Base : aujourd'hui 21/07/2026. Estimations en jours ouvrés, POC à effectif réduit (ordre de grandeur, pas un engagement contractuel).

Jalon 0 — Débloquer Vertex (J+2 à J+5)

  • Facturation + API Vertex AI activées sur kayro-502614.
  • Rôle IAM du service account vérifié, europe-west4/gemini-2.5-flash confirmés accessibles.
  • npx tsx scripts/vertical-slice-memoire.ts produit une vraie section générée (pas le repli concaténation).
  • Démontrable : un .docx réel avec une section « Moyens humains » rédigée par Gemini, traçable, montré à un chargé d'études pour un premier avis qualitatif (« mieux qu'une page blanche ? »).

Jalon 1 — Vertical slice généralisé + ingestion (J+5 à J+15)

  • Étapes 1 et 2 complètes : cadre parsé en toutes ses rubriques, blocs ingérés en Supabase, génération multi-thèmes fonctionnelle en CLI/script.
  • Démontrable : un mémoire complet (10-15 rubriques) généré de bout en bout pour un vrai AO en cours, comparé côte à côte avec un mémoire déposé historique sur une affaire similaire.

Jalon 2 — Modules A/D + bibliothèque canonique (J+15 à J+25)

  • Étapes 3 et 4, parallélisables.
  • Démontrable : /observabilite et l'onglet Analyse d'un AO terminé branchés sur les vraies données d'audit — démo direction sans risque (read-only), plus un premier lot de blocs canoniques validés dont celui du thème « Continuité/astreinte/urgence ».

Jalon 3 — Module B en production (J+25 à J+40)

  • Étape 5 complète : /ao/[id]/rediger branché sur le vrai pipeline, export .docx fonctionnel aux standards Serpe.
  • Démontrable : un chargé d'études rédige un mémoire réel dans l'app, de la sélection de rubriques à l'export, sans repasser par le script CLI. C'est la fin du jalon « conforme ».

Jalon 4 — Références/visuels + win-loss si donnée obtenue (au-delà de J+40)

  • Étape 6 systématiquement ; étape 7 conditionnelle à la réception de l'export Zoho (hors de notre calendrier).
  • Démontrable : mémoire exporté avec références structurées et visuels intégrés ; et, seulement si la donnée win/loss arrive, un premier signal « ce bloc vient d'une affaire gagnée » dans la sélection — début du jalon « compétitif ».