rahim237cm Claude Opus 5 commited on
Commit
ca48b04
·
1 Parent(s): 44b9a02

feat(extraction): remplacer LlamaParse par Gemini multimodal

Browse files

La cle LlamaParse ayant ete retiree, l'ingestion de tout nouveau PDF levait et
marquait le document `refused` — le bon echec, mais l'ingestion etait a l'arret.

LE MOTIF DU CHANGEMENT EST LA PAGINATION, PAS LE PRIX. LlamaParse rend le
document en un bloc sans signaler ses coupures de page ; `_split_markdown_pages`
les reconstruisait en coupant sur les lignes de trois tirets, et sa propre
docstring admettait la limite : « la pagination ne devient exacte qu'avec un
moteur qui renvoie un numero de page explicite ». Mesure en base : les 710
articles portent tous `page_number = 1`, et sur les 27 lois, 271 pages de PDF ne
donnent que 27 pages extraites — couverture moyenne 0,60. Un lien `?article=35`
ouvre donc le PDF a la premiere page.

MESURE AVANT D'ECRIRE, sur le Code Minier dont la sortie LlamaParse est en base :

densite 2352 car/page contre 2405 — equivalent, rien n'est perdu
marqueurs de page 1 par page contre 1 pour 69 pages
lot pages 21 a 32 numerote 21..32 exactement

Le dernier point n'est acquis que si la consigne annonce au modele le numero de
la premiere page fournie. Sans elle il renumerote chaque lot a partir de 1, et
chaque lot ecrase la pagination du precedent.

UN MODE D'ECHEC PROPRE A CE MOTEUR. Gemini rend parfois
`finish_reason=RECITATION` et refuse de transcrire une page d'acte officiel dont
la forme lui est familiere. Mesure sur sept documents : une page de garde
refusee, soit ~14 %. Ce n'est pas transitoire — trois formulations de consigne
essayees, toutes refusees — mais c'est PAR PAGE : dans le document mesure, la
page 1 refuse et les pages 2 et 3 s'extraient. Le lot est donc rejoue page par
page, seule la page refusee est perdue, et elle est nommee dans
`processing_error`. Perdre une page de garde vaut mieux que perdre les
soixante-huit autres.

CE QUI EST CONSERVE. Une centaine de lignes de `llama_parse_service.py` ne
dependaient d'aucun fournisseur et avaient ete calibrees sur ce corpus : le
retrait du cachet « COPIE CERTIFIEE CONFORME » avec sa regle des deux
occurrences — qui epargne un en-tete « PRESIDENCE DE LA REPUBLIQUE » legitime —,
le filigrane `www.prc.cm` eclate caractere par caractere, et le retrait de
`<sup>` sans jamais toucher `<table>`. Elles vivent dans
`app/utils/markdown_cleanup.py` avec leurs six tests.

N'est PAS conserve `_split_markdown_pages` : reconstruire par heuristique ce que
le nouveau moteur donne exactement reviendrait a deviner ce qu'on connait.

Le cache disque est repris tel quel — indexe par sha256 du fichier, ecriture
atomique, une extraction vide n'est jamais gravee — avec `tier` devenu `engine`.
Les 61 entrees LlamaParse ne sont donc pas relues, ce qui est correct : elles
contiennent la sortie non paginee qu'on remplace. Elles ne sont pas detruites.

Un defaut recopie de l'ancien service puis corrige : `api_key=""` passe
explicitement retombait sur le reglage global, rendant intestable le cas du
service non configure.

Quota : le palier gratuit plafonne a 20 generations par jour. Les 27 lois en
base coutent 35 appels, soit deux jours. `reextract_corpus.py` est reprenable
par construction — le cache par sha256 fait qu'un document deja extrait n'est
jamais repaye — s'arrete proprement sur 429 et sort en code 2.

Tests : 725 -> 752, dont 21 sur le nouveau service et 6 migres avec le
nettoyage. Le reseau est simule par monkeypatch et non par respx : le SDK
google-genai a sa propre pile, qu'un mock respx ne verrait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

.env.example CHANGED
@@ -23,14 +23,13 @@ GEMINI_API_KEY=your-gemini-api-key-here
23
  GEMINI_MODEL=gemini-3.8-flash
24
  GEMINI_EMBEDDING_MODEL=models/gemini-embedding-001
25
 
26
- # ---- LlamaParse (LlamaCloud) — extraction OCR des PDF scannes ----
27
- # Cle a creer sur https://cloud.llamaindex.ai
28
- LLAMA_CLOUD_API_KEY=llx-your-llama-parse-key-here
29
- # Tier : fast(1cr/page) | cost_effective(3cr) | agentic(10cr) | agentic_plus(45cr)
30
- # 1000 credits = 1,25 $. cost_effective mesure a 100% de rappel sur le corpus prc.cm ;
31
- # fast relit la couche texte existante (50% seulement) — ne pas l'utiliser ici.
32
- LLAMA_PARSE_TIER=cost_effective
33
- # Cache OCR par sha256 : un fichier deja extrait n'est jamais repaye
34
  OCR_CACHE_DIR=./data/ocr_cache
35
 
36
  # ---- Securite / JWT ----
 
23
  GEMINI_MODEL=gemini-3.8-flash
24
  GEMINI_EMBEDDING_MODEL=models/gemini-embedding-001
25
 
26
+ # ---- Extraction des PDF ----
27
+ # L'extraction passe par Gemini multimodal : meme cle que la generation.
28
+ # Pages envoyees par appel. La limite du modele est de 65 536 jetons EN SORTIE ;
29
+ # a ~2400 caracteres par page, 20 pages en produisent ~13 000. Le palier gratuit
30
+ # plafonne a 20 appels par jour : agrandir le lot economise des appels.
31
+ # PDF_EXTRACTION_PAGES_PER_CALL=20
32
+ # Cache d'extraction par sha256 : un fichier deja traite n'est jamais repaye
 
33
  OCR_CACHE_DIR=./data/ocr_cache
34
 
35
  # ---- Securite / JWT ----
README.md CHANGED
@@ -16,7 +16,7 @@ article, réponses citées via Gemini.
16
  | Recherche sémantique | `pgvector` + index HNSW, embeddings `gemini-embedding-001` tronqués à 1536 dim. |
17
  | Cache | tables `query_cache` et `embedding_cache` |
18
  | LLM | Gemini (`google-genai`) |
19
- | OCR | LlamaParse v2 |
20
  | Tâches de fond | `BackgroundTasks` FastAPI |
21
 
22
  Pas de Redis, pas de Meilisearch, pas de Celery : recherche, cache et files
@@ -82,7 +82,7 @@ Toutes les variables de `.env.example` sont réellement lues par
82
  |---|---|
83
  | `DATABASE_URL` | `postgresql+asyncpg://…` — le driver asyncpg est obligatoire |
84
  | `GEMINI_API_KEY` / `GEMINI_MODEL` | LLM des réponses. Modèles Flash actuels : `gemini-3.8-flash`, `3.7`, `3.6`, `3.5` |
85
- | `LLAMA_CLOUD_API_KEY` / `LLAMA_PARSE_TIER` | OCR. `cost_effective` est le tier recommandé |
86
  | `SECRET_KEY` | Signature JWT. **Obligatoire hors développement** : l'application refuse de démarrer si la valeur du dépôt est conservée |
87
  | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Valeurs par défaut de `scripts/create_admin.py` |
88
 
@@ -103,7 +103,7 @@ créés par `POST /api/v1/admin/users` ou par `scripts/create_admin.py`.
103
  ## Chaîne d'ingestion
104
 
105
  ```
106
- upload → LlamaParse → normalisation → découpage par article
107
  → embeddings → tsvector (trigger) → published
108
  ```
109
 
@@ -111,14 +111,28 @@ Un document est créé en `processing`. Il passe à `published` en cas de succè
111
  à `refused` avec `processing_error` en cas d'échec — jamais de contenu
112
  partiellement extrait dans le corpus public.
113
 
114
- **Aucun repli dégradé.** LlamaParse réessaie 4 fois avec un délai croissant ;
115
- au-delà, l'ingestion échoue. La couche texte des PDF scannés du corpus a été
116
- mesurée à environ 20 % de rappel (filigrane inséré au milieu des phrases,
117
- cachets lus comme du charabia, un document sur cinq sans aucun texte
118
- exploitable) : la publier serait pire que de ne rien publier.
119
-
120
- Les extractions sont mises en cache par `sha256` : un fichier déjà traité n'est
121
- jamais repayé.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  ## Découpage
124
 
 
16
  | Recherche sémantique | `pgvector` + index HNSW, embeddings `gemini-embedding-001` tronqués à 1536 dim. |
17
  | Cache | tables `query_cache` et `embedding_cache` |
18
  | LLM | Gemini (`google-genai`) |
19
+ | Extraction PDF | Gemini multimodal |
20
  | Tâches de fond | `BackgroundTasks` FastAPI |
21
 
22
  Pas de Redis, pas de Meilisearch, pas de Celery : recherche, cache et files
 
82
  |---|---|
83
  | `DATABASE_URL` | `postgresql+asyncpg://…` — le driver asyncpg est obligatoire |
84
  | `GEMINI_API_KEY` / `GEMINI_MODEL` | LLM des réponses. Modèles Flash actuels : `gemini-3.8-flash`, `3.7`, `3.6`, `3.5` |
85
+ | `PDF_EXTRACTION_PAGES_PER_CALL` | Pages envoyées par appel d'extraction (défaut 20) |
86
  | `SECRET_KEY` | Signature JWT. **Obligatoire hors développement** : l'application refuse de démarrer si la valeur du dépôt est conservée |
87
  | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Valeurs par défaut de `scripts/create_admin.py` |
88
 
 
103
  ## Chaîne d'ingestion
104
 
105
  ```
106
+ upload → extraction Gemini → normalisation → découpage par article
107
  → embeddings → tsvector (trigger) → published
108
  ```
109
 
 
111
  à `refused` avec `processing_error` en cas d'échec — jamais de contenu
112
  partiellement extrait dans le corpus public.
113
 
114
+ **Aucun repli dégradé.** L'extraction réessaie trois fois sur une saturation du
115
+ fournisseur (503), jamais sur un quota épuisé (429) ; au-delà, l'ingestion
116
+ échoue. La couche texte des PDF scannés du corpus a été mesurée à environ 20 %
117
+ de rappel (filigrane inséré au milieu des phrases, cachets lus comme du
118
+ charabia, un document sur cinq sans aucun texte exploitable) : la publier serait
119
+ pire que de ne rien publier.
120
+
121
+ **Pagination.** Gemini rend un marqueur `<<PAGE:n>>` par page, ce que LlamaParse
122
+ ne faisait pas — d'où des articles portant tous `page_number = 1`. C'est la
123
+ raison du changement de moteur, pas le prix.
124
+
125
+ **Refus de transcription.** Le modèle rend parfois `finish_reason=RECITATION`
126
+ sur une page d'acte officiel dont la forme lui est familière : mesuré à une page
127
+ de garde sur sept documents. Le refus est **par page** — le lot est alors rejoué
128
+ page par page, et seule la page refusée est perdue, signalée dans
129
+ `processing_error`. Reformuler la consigne n'y change rien (trois variantes
130
+ essayées), réduire le lot non plus.
131
+
132
+ **Quota.** Le palier gratuit plafonne à 20 appels de génération par jour et par
133
+ modèle. Un document d'une page coûte un appel ; les 72 PDF locaux (481 pages) en
134
+ coûtent 83. Le script de ré-extraction est reprenable : le cache `sha256` fait
135
+ qu'un fichier déjà traité n'est jamais repayé.
136
 
137
  ## Découpage
138
 
app/core/config.py CHANGED
@@ -71,13 +71,17 @@ class Settings(BaseSettings):
71
  TEXT_WEIGHT: float = 0.4
72
  SEMANTIC_WEIGHT: float = 0.6
73
 
74
- # LlamaParse (PDF extraction OCR)
75
- LLAMA_CLOUD_API_KEY: str = ""
76
- # Tier de parsing : fast(1cr) | cost_effective(3cr) | agentic(10cr) | agentic_plus(45cr)
77
- # cost_effective est le meilleur rapport qualite/prix mesure sur le corpus prc.cm
78
- LLAMA_PARSE_TIER: str = "cost_effective"
79
- # Cache OCR par sha256 — evite de repayer l'extraction d'un fichier deja traite
80
  OCR_CACHE_DIR: str = "./data/ocr_cache"
 
 
 
 
 
 
81
 
82
  # CORS — comma-separated list of extra allowed origins for production
83
  # Example: https://jurix.vercel.app,https://www.jurix.cm
 
71
  TEXT_WEIGHT: float = 0.4
72
  SEMANTIC_WEIGHT: float = 0.6
73
 
74
+ # LLAMA_CLOUD_API_KEY et LLAMA_PARSE_TIER ont ete retires : l'extraction
75
+ # passe par Gemini (app/services/pdf_extraction_service.py), qui rend un
76
+ # numero de page explicite la ou LlamaParse n'en donnait aucun.
77
+ # Cache d'extraction par sha256 — evite de repayer un fichier deja traite
 
 
78
  OCR_CACHE_DIR: str = "./data/ocr_cache"
79
+ # Pages envoyees par appel a Gemini. La limite du modele est de 65 536
80
+ # jetons EN SORTIE ; a ~2400 caracteres par page, 20 pages produisent
81
+ # ~13 000 jetons, avec de la marge pour la reflexion interne. Le palier
82
+ # gratuit plafonnant a 20 appels par jour, agrandir le lot economise des
83
+ # appels — au risque de tronquer si les pages sont denses.
84
+ PDF_EXTRACTION_PAGES_PER_CALL: int = 20
85
 
86
  # CORS — comma-separated list of extra allowed origins for production
87
  # Example: https://jurix.vercel.app,https://www.jurix.cm
app/services/llama_parse_service.py DELETED
@@ -1,594 +0,0 @@
1
- """
2
- LlamaParseService - Extraction OCR des documents juridiques via LlamaParse v2.
3
-
4
- Remplace l'extraction par couche texte (pdfplumber/pypdf), inexploitable sur le
5
- corpus prc.cm : 21% des documents n'ont aucune couche texte utilisable, et les
6
- 79% restants sont pollués par le filigrane www.prc.cm et les cachets officiels.
7
-
8
- Banc d'essai sur 6 pages representatives (verite terrain relevee visuellement) :
9
-
10
- moteur prose tableau budgetaire
11
- couche texte actuelle 20% 0/9
12
- tesseract 5.5 local 74% 0/9
13
- mistral OCR 100% 8/9 (chiffre faux, reproductible)
14
- llamaparse cost_effective 100% 9/9
15
-
16
- Architecture :
17
- - API REST v2 : POST /parse/upload -> poll GET /parse/{id} -> markdown
18
- - Cache disque par sha256 : une page n'est jamais payee deux fois
19
- - Retry exponentiel : 1 echec reseau observe sur 6 appels au banc
20
- - Decoupe automatique des PDF volumineux (5 fichiers >50 Mo dans le corpus)
21
- - Marqueurs <<PAGE:n>> pour que text_chunker renseigne Article.page_number
22
- - Nettoyage des cachets officiels (regle 7 du chunking)
23
-
24
- Tiers disponibles (credits/page, 1000 credits = 1,25 $) :
25
- fast 1 relit la couche texte -> 50% de rappel, inutilisable ici
26
- cost_effective 3 recommande : 100% de rappel, 9/9 sur les tableaux
27
- agentic 10 aucun gain mesure sur ce corpus
28
- agentic_plus 45 aucun gain mesure sur ce corpus
29
-
30
- Author: JuriX Team
31
- """
32
-
33
- import asyncio
34
- import hashlib
35
- import json
36
- import logging
37
- import re
38
- import time
39
- from functools import lru_cache
40
- from pathlib import Path
41
- from typing import Any, Dict, List, Optional
42
-
43
- import httpx
44
-
45
- from app.core.config import settings
46
-
47
- logger = logging.getLogger(__name__)
48
-
49
-
50
- class LlamaParseError(Exception):
51
- """Erreur d'extraction LlamaParse."""
52
- pass
53
-
54
-
55
- # ==================== NETTOYAGE DES CACHETS ====================
56
-
57
- # Vocabulaire du cachet "COPIE CERTIFIEE CONFORME" appose sur chaque page du
58
- # corpus prc.cm. LlamaParse le restitue en texte (contrairement a Mistral OCR
59
- # qui l'ignore), il faut donc le retirer avant chunking sinon il pollue le FTS
60
- # et les embeddings de chaque document.
61
- _STAMP_VOCAB = re.compile(
62
- r"(copie\s+certifi\w*\s+conforme"
63
- r"|certified\s+true\s+copy"
64
- r"|service\s+du\s+fichier\s+l[ée]gislatif"
65
- r"|legislative\s+and\s+statutory\s+affairs"
66
- r"|presidency\s+of\s+the\s+republic"
67
- r"|secr[ée]tariat[\s-]+g[ée]n[ée]ral"
68
- r"|pr[ée]sidence\s+de\s+la\s+r[ée]publique)",
69
- re.IGNORECASE,
70
- )
71
-
72
- # Fragments du filigrane diagonal "www.prc.cm" eclate par l'OCR
73
- _WATERMARK = re.compile(
74
- r"^\s*(?:w\s*){1,3}$|^\s*\.?\s*(?:p\s*r\s*c|c\s*m|prc\.cm|www\.prc\.cm)\s*\.?\s*$",
75
- re.IGNORECASE,
76
- )
77
-
78
- # Balisage HTML inline emis par LlamaParse (les <table> sont conserves : la
79
- # structure tabulaire porte du sens, cf. les annexes budgetaires).
80
- # <sup>/<sub> sont critiques : "ARTICLE 1<sup>ER</sup>" doit redevenir
81
- # "ARTICLE 1ER" pour que ARTICLE_PATTERNS de text_chunker le reconnaisse.
82
- _INLINE_TAGS = re.compile(
83
- r"</?(?:u|mark|b|i|em|strong|span|sup|sub|small)\b[^>]*>", re.IGNORECASE
84
- )
85
-
86
-
87
- def _strip_stamp_blocks(text: str) -> str:
88
- """
89
- Retire les blocs de cachet officiel du markdown.
90
-
91
- Un bloc = au moins 2 lignes consecutives (blancs autorises) appartenant au
92
- vocabulaire du cachet. Le seuil de 2 evite de supprimer un en-tete legitime :
93
- beaucoup de documents portent "PRESIDENCE DE LA REPUBLIQUE" comme autorite
94
- emettrice, ce qui est du contenu, pas du tampon.
95
-
96
- Args:
97
- text: Markdown brut renvoye par LlamaParse
98
-
99
- Returns:
100
- Markdown nettoye
101
- """
102
- lines = text.splitlines()
103
- drop = [False] * len(lines)
104
- i = 0
105
-
106
- while i < len(lines):
107
- if not _STAMP_VOCAB.search(lines[i]):
108
- i += 1
109
- continue
110
-
111
- # Cas 1 : cachet entier restitue sur une seule ligne, par ex.
112
- # "[signature: PRESIDENCE ... COPIE CERTIFIEE CONFORME CERTIFIED TRUE COPY]"
113
- # ou "stamp: ..." / "logo: ...". Deux phrases distinctes du vocabulaire sur
114
- # la meme ligne ne peuvent pas etre du texte juridique legitime.
115
- if len(set(m.group(0).lower() for m in _STAMP_VOCAB.finditer(lines[i]))) >= 2:
116
- drop[i] = True
117
- i += 1
118
- continue
119
-
120
- # Etendre le bloc tant qu'on reste dans le vocabulaire du cachet
121
- j = i
122
- hits = 0
123
- while j < len(lines):
124
- stripped = lines[j].strip(" >*|-\t")
125
- if _STAMP_VOCAB.search(lines[j]):
126
- hits += 1
127
- j += 1
128
- elif not stripped:
129
- j += 1
130
- else:
131
- break
132
-
133
- if hits >= 2:
134
- for k in range(i, j):
135
- drop[k] = True
136
- i = max(j, i + 1)
137
-
138
- kept = [ln for ln, d in zip(lines, drop) if not d]
139
- kept = [ln for ln in kept if not _WATERMARK.match(ln)]
140
- cleaned = "\n".join(kept)
141
- cleaned = _INLINE_TAGS.sub("", cleaned)
142
- return re.sub(r"\n{4,}", "\n\n\n", cleaned).strip()
143
-
144
-
145
- # ==================== DECOUPE EN PAGES ====================
146
-
147
- # Saut de page emis par LlamaParse quand la reponse arrive en un seul bloc :
148
- # une ligne contenant EXACTEMENT trois tirets.
149
- #
150
- # Les trois contraintes sont mesurees, pas supposees (64 documents apparies a
151
- # leur PDF, 473 pages de verite terrain) :
152
- # - ligne entiere : `|---|---|`, separateur de tableau markdown, ne
153
- # peut pas declencher de coupure.
154
- # - exactement trois : `-{3,}` donnait 10 sur-decoupes et 44/64 exacts ;
155
- # `-{3}` en donne 3 et 51/64. Les suites plus
156
- # longues sont du contenu (soulignements, bordures
157
- # ASCII), jamais des sauts de page.
158
- _PAGE_BREAK = re.compile(r"\n-{3}[ \t]*(?=\n)")
159
-
160
- # Frontmatter YAML en tete de document. Ses deux delimiteurs sont des lignes de
161
- # trois tirets : sans ce retrait prealable, le delimiteur FERMANT serait pris
162
- # pour un saut de page et couperait le document en deux.
163
- _FRONTMATTER = re.compile(r"\A---[ \t]*\n.*?\n---[ \t]*(?=\n|\Z)", re.DOTALL)
164
-
165
-
166
- def _split_markdown_pages(markdown: str) -> List[str]:
167
- """
168
- Restitue la structure de pages d'un markdown recu en un seul bloc.
169
-
170
- POURQUOI. `_pages_from` renvoyait `[md]` des que la reponse etait une
171
- chaine, ce qui ecrasait la pagination de TOUT le document : les 849 articles
172
- en base portaient tous `page_number = 1`. Les vraies coupures etaient
173
- pourtant presentes dans le texte, simplement jamais exploitees.
174
-
175
- RESULTAT MESURE sur 64 documents apparies a leur PDF (473 pages reelles) :
176
- 64 pages restituees avant, 411 apres, dont 51/64 documents au compte exact
177
- et 58/64 a une page pres. Les 10 documents restants sont sous-decoupes
178
- parce que LlamaParse n'a emis aucun separateur pour ces pages-la : c'est une
179
- reconstruction heuristique, pas une source d'autorite. La pagination ne
180
- devient exacte qu'avec un moteur qui renvoie un numero de page explicite.
181
-
182
- Les pages vides sont CONSERVEES : `extract_text` numerote avant de filtrer
183
- (`enumerate(..., start=1)` puis `if md.strip()`), donc supprimer une page
184
- blanche ici decalerait tous les numeros suivants.
185
-
186
- Repli prudent : si la coupure ne produit pas au moins deux pages non vides,
187
- le document est rendu tel quel. Un texte sans saut de page reste donc une
188
- page unique, comme avant.
189
- """
190
- # Le frontmatter est mis de cote AVANT la coupure : son delimiteur fermant
191
- # est lui aussi une ligne de trois tirets, et couperait la premiere page.
192
- front = _FRONTMATTER.match(markdown)
193
- head, body = (front.group(0), markdown[front.end():]) if front else ("", markdown)
194
-
195
- parts = _PAGE_BREAK.split(body)
196
- if sum(1 for p in parts if p.strip()) < 2:
197
- return [markdown]
198
-
199
- # Un saut en tete ou en pied de document produit une page vide parasite,
200
- # qui n'en est pas une. Les pages vides INTERIEURES, elles, sont gardees.
201
- while parts and not parts[0].strip():
202
- parts.pop(0)
203
- while parts and not parts[-1].strip():
204
- parts.pop()
205
-
206
- pages = [p.strip("\n") for p in parts]
207
- if head and pages:
208
- pages[0] = f"{head}\n{pages[0]}".strip("\n")
209
- return pages
210
-
211
-
212
- class LlamaParseService:
213
- """
214
- Client LlamaParse v2 pour l'extraction des PDF juridiques scannes.
215
-
216
- Usage:
217
- >>> service = LlamaParseService()
218
- >>> if service.is_available():
219
- ... text = await service.extract_text(Path("decret.pdf"))
220
- """
221
-
222
- BASE_URL = "https://api.cloud.llamaindex.ai/api/v2/parse"
223
- DEFAULT_TIER = "cost_effective"
224
-
225
- # Decoupe defensive : seuil conservateur, en-deca de la limite annoncee.
226
- # Le corpus contient 5 fichiers >50 Mo (max observe : 124,6 Mo / 48 pages).
227
- # Version du format d'entree de cache. A incrementer des qu'un changement
228
- # rend les entrees existantes trompeuses plutot que simplement vieilles.
229
- # v2 : `_pages_from` decoupe desormais un markdown recu en un seul bloc ;
230
- # les entrees v1 rendaient tout le document sur une page unique.
231
- CACHE_SCHEMA = 2
232
-
233
- MAX_FILE_MB = 45
234
- SPLIT_PAGES = 100
235
-
236
- MAX_RETRIES = 4
237
- RETRY_BASE_DELAY = 3.0
238
- POLL_INTERVAL = 3.0
239
- POLL_TIMEOUT = 900.0
240
- UPLOAD_TIMEOUT = 300.0
241
-
242
- def __init__(
243
- self,
244
- api_key: Optional[str] = None,
245
- tier: Optional[str] = None,
246
- cache_dir: Optional[Path] = None,
247
- ):
248
- """
249
- Initialise le service.
250
-
251
- Args:
252
- api_key: Cle LlamaCloud (defaut: settings.LLAMA_CLOUD_API_KEY)
253
- tier: Tier de parsing (defaut: settings.LLAMA_PARSE_TIER)
254
- cache_dir: Repertoire du cache sha256 (defaut: ./data/ocr_cache)
255
- """
256
- self.api_key = api_key or settings.LLAMA_CLOUD_API_KEY
257
- self.tier = tier or getattr(settings, "LLAMA_PARSE_TIER", self.DEFAULT_TIER)
258
-
259
- self.cache_dir = Path(
260
- cache_dir or getattr(settings, "OCR_CACHE_DIR", "./data/ocr_cache")
261
- )
262
- try:
263
- self.cache_dir.mkdir(parents=True, exist_ok=True)
264
- except OSError as e:
265
- logger.warning(f"⚠️ Cache OCR indisponible ({e}), desactive")
266
- self.cache_dir = None
267
-
268
- if self.api_key:
269
- logger.info(f"✅ LlamaParseService pret (tier={self.tier})")
270
- else:
271
- logger.warning("⚠️ LLAMA_CLOUD_API_KEY absente — LlamaParse desactive")
272
-
273
- # ==================== API PUBLIQUE ====================
274
-
275
- def is_available(self) -> bool:
276
- """Le service est-il configure ?"""
277
- return bool(self.api_key)
278
-
279
- async def extract_text(self, file_path: Path) -> str:
280
- """
281
- Extrait le texte complet d'un PDF, avec marqueurs de page.
282
-
283
- Point d'entree utilise par app/tasks/process_law.py.
284
-
285
- Args:
286
- file_path: Chemin du PDF
287
-
288
- Returns:
289
- Markdown nettoye, pages separees par <<PAGE:n>>
290
-
291
- Raises:
292
- LlamaParseError: Si l'extraction echoue apres tous les retries
293
- """
294
- pages = await self.extract_pages(file_path)
295
- if not pages:
296
- raise LlamaParseError(f"Aucune page extraite de {file_path.name}")
297
-
298
- parts = [f"<<PAGE:{i}>>\n{md}" for i, md in enumerate(pages, start=1) if md.strip()]
299
- text = "\n\n".join(parts)
300
- logger.info(
301
- f"✅ LlamaParse: {len(pages)} page(s), {len(text)} caracteres "
302
- f"depuis {file_path.name}"
303
- )
304
- return text
305
-
306
- async def extract_pages(self, file_path: Path) -> List[str]:
307
- """
308
- Extrait le markdown page par page (cache-aware).
309
-
310
- Args:
311
- file_path: Chemin du PDF
312
-
313
- Returns:
314
- Liste de markdown, un element par page, dans l'ordre
315
- """
316
- assert file_path is not None, "file_path requis"
317
- if not self.is_available():
318
- raise LlamaParseError("LLAMA_CLOUD_API_KEY non configuree")
319
- if not file_path.exists():
320
- raise LlamaParseError(f"Fichier introuvable: {file_path}")
321
-
322
- cache_key = self._sha256(file_path)
323
- cached = self._read_cache(cache_key)
324
- if cached is not None:
325
- logger.info(f"🎯 Cache OCR HIT: {file_path.name} ({len(cached)} pages)")
326
- return cached
327
-
328
- chunks = self._split_if_needed(file_path)
329
- pages: List[str] = []
330
- try:
331
- for idx, chunk_path in enumerate(chunks, start=1):
332
- if len(chunks) > 1:
333
- logger.info(f"📄 Segment {idx}/{len(chunks)}: {chunk_path.name}")
334
- pages.extend(await self._parse_one(chunk_path))
335
- finally:
336
- for c in chunks:
337
- if c != file_path:
338
- c.unlink(missing_ok=True)
339
-
340
- pages = [_strip_stamp_blocks(p) for p in pages]
341
- self._write_cache(cache_key, pages)
342
- return pages
343
-
344
- async def health_check(self) -> Dict[str, Any]:
345
- """Verifie la configuration et la joignabilite de l'API."""
346
- if not self.is_available():
347
- return {"service": "LlamaParse", "status": "unconfigured", "tier": self.tier}
348
- try:
349
- async with httpx.AsyncClient(timeout=15.0) as client:
350
- r = await client.get(
351
- f"{self.BASE_URL}/00000000-0000-0000-0000-000000000000",
352
- headers=self._headers(),
353
- )
354
- reachable = r.status_code in (200, 401, 403, 404, 422)
355
- return {
356
- "service": "LlamaParse",
357
- "status": "healthy" if reachable else "degraded",
358
- "tier": self.tier,
359
- "cache_dir": str(self.cache_dir) if self.cache_dir else None,
360
- }
361
- except Exception as e:
362
- return {"service": "LlamaParse", "status": "unreachable", "error": str(e)}
363
-
364
- # ==================== APPEL API ====================
365
-
366
- def _headers(self) -> Dict[str, str]:
367
- return {"Authorization": f"Bearer {self.api_key}"}
368
-
369
- async def _parse_one(self, file_path: Path) -> List[str]:
370
- """Soumet un fichier, attend le job, renvoie le markdown par page."""
371
- last_error: Optional[Exception] = None
372
-
373
- for attempt in range(1, self.MAX_RETRIES + 1):
374
- try:
375
- job_id = await self._upload(file_path)
376
- return await self._poll(job_id)
377
- except LlamaParseError:
378
- raise
379
- except Exception as e:
380
- last_error = e
381
- if attempt < self.MAX_RETRIES:
382
- delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1))
383
- logger.warning(
384
- f"⚠️ Tentative {attempt}/{self.MAX_RETRIES} echouee "
385
- f"({type(e).__name__}: {e}), retry dans {delay:.0f}s"
386
- )
387
- await asyncio.sleep(delay)
388
-
389
- raise LlamaParseError(
390
- f"Echec apres {self.MAX_RETRIES} tentatives sur {file_path.name}: {last_error}"
391
- )
392
-
393
- async def _upload(self, file_path: Path) -> str:
394
- """Soumet le fichier et renvoie l'id du job."""
395
- config = json.dumps({"tier": self.tier, "version": "latest"})
396
-
397
- async with httpx.AsyncClient(timeout=self.UPLOAD_TIMEOUT) as client:
398
- with file_path.open("rb") as fh:
399
- resp = await client.post(
400
- f"{self.BASE_URL}/upload",
401
- headers=self._headers(),
402
- files={"file": (file_path.name, fh, "application/pdf")},
403
- data={"configuration": config},
404
- )
405
-
406
- if resp.status_code in (401, 403):
407
- raise LlamaParseError(f"Cle LlamaCloud refusee (HTTP {resp.status_code})")
408
- if resp.status_code not in (200, 201):
409
- raise RuntimeError(f"upload HTTP {resp.status_code}: {resp.text[:300]}")
410
-
411
- payload = resp.json()
412
- job_id = (payload.get("job") or {}).get("id") or payload.get("id")
413
- if not job_id:
414
- raise RuntimeError(f"Pas de job id dans la reponse: {str(payload)[:300]}")
415
- return job_id
416
-
417
- async def _poll(self, job_id: str) -> List[str]:
418
- """Interroge le job jusqu'a completion, renvoie le markdown par page."""
419
- started = time.monotonic()
420
-
421
- async with httpx.AsyncClient(timeout=120.0) as client:
422
- while True:
423
- if time.monotonic() - started > self.POLL_TIMEOUT:
424
- raise LlamaParseError(
425
- f"Timeout ({self.POLL_TIMEOUT:.0f}s) sur le job {job_id}"
426
- )
427
- await asyncio.sleep(self.POLL_INTERVAL)
428
-
429
- resp = await client.get(
430
- f"{self.BASE_URL}/{job_id}",
431
- headers=self._headers(),
432
- params={"expand": "markdown,markdown_full"},
433
- )
434
- if resp.status_code != 200:
435
- raise RuntimeError(f"poll HTTP {resp.status_code}: {resp.text[:300]}")
436
-
437
- data = resp.json()
438
- job = data.get("job") or {}
439
- status = job.get("status") or data.get("status")
440
-
441
- if status in ("COMPLETED", "SUCCESS"):
442
- return self._pages_from(data)
443
- if status in ("FAILED", "CANCELLED", "ERROR"):
444
- raise LlamaParseError(
445
- f"Job {status}: {job.get('error_message') or 'raison inconnue'}"
446
- )
447
-
448
- @staticmethod
449
- def _pages_from(data: Dict[str, Any]) -> List[str]:
450
- """Normalise la reponse v2 en liste de markdown par page."""
451
- md = data.get("markdown")
452
-
453
- if isinstance(md, list):
454
- return [
455
- (p.get("markdown", "") if isinstance(p, dict) else str(p)) for p in md
456
- ]
457
- if isinstance(md, str) and md.strip():
458
- return _split_markdown_pages(md)
459
-
460
- pages = data.get("pages")
461
- if isinstance(pages, list):
462
- return [
463
- (p.get("markdown", "") if isinstance(p, dict) else str(p)) for p in pages
464
- ]
465
-
466
- full = data.get("markdown_full")
467
- return _split_markdown_pages(full) if isinstance(full, str) and full.strip() else []
468
-
469
- # ==================== DECOUPE DES GROS FICHIERS ====================
470
-
471
- def _split_if_needed(self, file_path: Path) -> List[Path]:
472
- """
473
- Decoupe un PDF trop volumineux en segments temporaires.
474
-
475
- Returns:
476
- [file_path] si aucune decoupe n'est necessaire, sinon les segments.
477
- """
478
- size_mb = file_path.stat().st_size / 1_000_000
479
- if size_mb <= self.MAX_FILE_MB:
480
- return [file_path]
481
-
482
- try:
483
- from pypdf import PdfReader, PdfWriter
484
- except ImportError:
485
- logger.warning(
486
- f"⚠️ pypdf absent, {file_path.name} ({size_mb:.0f} Mo) envoye tel quel"
487
- )
488
- return [file_path]
489
-
490
- reader = PdfReader(str(file_path))
491
- total = len(reader.pages)
492
- logger.info(
493
- f"✂️ {file_path.name}: {size_mb:.0f} Mo / {total} pages -> "
494
- f"segments de {self.SPLIT_PAGES} pages"
495
- )
496
-
497
- parts: List[Path] = []
498
- for start in range(0, total, self.SPLIT_PAGES):
499
- writer = PdfWriter()
500
- for page in reader.pages[start:start + self.SPLIT_PAGES]:
501
- writer.add_page(page)
502
- part = file_path.with_name(f"{file_path.stem}__part{start // self.SPLIT_PAGES}.pdf")
503
- with part.open("wb") as fh:
504
- writer.write(fh)
505
- parts.append(part)
506
-
507
- return parts
508
-
509
- # ==================== CACHE SHA256 ====================
510
-
511
- @staticmethod
512
- def _sha256(file_path: Path) -> str:
513
- h = hashlib.sha256()
514
- with file_path.open("rb") as fh:
515
- for block in iter(lambda: fh.read(1024 * 1024), b""):
516
- h.update(block)
517
- return h.hexdigest()
518
-
519
- def _cache_path(self, key: str) -> Optional[Path]:
520
- return self.cache_dir / f"{key}.json" if self.cache_dir else None
521
-
522
- def _read_cache(self, key: str) -> Optional[List[str]]:
523
- path = self._cache_path(key)
524
- if not path or not path.exists():
525
- return None
526
- try:
527
- payload = json.loads(path.read_text(encoding="utf-8"))
528
- if payload.get("tier") != self.tier:
529
- return None
530
-
531
- pages = payload.get("pages")
532
-
533
- # Entree ecrite avant le decoupage en pages : elle rend tout le
534
- # document sur une seule page. Son CONTENU reste valide et a deja
535
- # ete paye — on le redecoupe sur place plutot que de rappeler l'API.
536
- # Re-extraire aurait coute des credits pour un texte qu'on possede
537
- # deja.
538
- if payload.get("schema") != self.CACHE_SCHEMA and pages:
539
- pages = _split_markdown_pages("\n\n".join(pages))
540
- logger.info(f"♻️ Cache OCR v1 redecoupe sur place : {len(pages)} page(s)")
541
- self._write_cache(key, pages)
542
-
543
- # Une liste VIDE etait un HIT : une extraction ratee se gravait sur
544
- # disque et se rejouait indefiniment, sans jamais rappeler l'API. Le
545
- # seul moyen d'en sortir etait d'effacer le fichier a la main.
546
- if not pages:
547
- logger.warning("⚠️ Entree de cache OCR vide, ignoree")
548
- return None
549
- return pages
550
- except Exception as e:
551
- logger.warning(f"⚠️ Cache OCR illisible ({e}), ignore")
552
- return None
553
-
554
- def _write_cache(self, key: str, pages: List[str]) -> None:
555
- path = self._cache_path(key)
556
- if not path:
557
- return
558
-
559
- # Ne jamais graver un echec. Sans ce garde-fou, `pages == []` — ce que
560
- # `_pages_from` rend des que la reponse n'a aucune forme reconnue, y
561
- # compris sur un job COMPLETED au markdown nul — devenait un resultat
562
- # permanent.
563
- if not any(p.strip() for p in pages):
564
- logger.warning("⚠️ Extraction vide, non mise en cache")
565
- return
566
-
567
- try:
568
- # Ecriture atomique : un plantage en cours d'ecriture laissait un
569
- # JSON tronque. Il etait relu comme un miss, donc non fatal, mais il
570
- # occupait la place de l'entree valide.
571
- tmp = path.with_suffix(".json.tmp")
572
- tmp.write_text(
573
- json.dumps(
574
- {
575
- "schema": self.CACHE_SCHEMA,
576
- "tier": self.tier,
577
- "pages": pages,
578
- "cached_at": time.time(),
579
- },
580
- ensure_ascii=False,
581
- ),
582
- encoding="utf-8",
583
- )
584
- tmp.replace(path)
585
- except OSError as e:
586
- logger.warning(f"⚠️ Ecriture cache OCR echouee ({e})")
587
-
588
-
589
- @lru_cache()
590
- def get_llama_parse_service() -> LlamaParseService:
591
- """Singleton LlamaParseService."""
592
- return LlamaParseService()
593
-
594
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/pdf_extraction_service.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extraction de PDF en markdown par Gemini multimodal.
3
+
4
+ Remplace LlamaParse. La raison n'est pas le prix mais la PAGINATION.
5
+
6
+ LlamaParse rend le document en un bloc et ne signale pas ses coupures de page ;
7
+ `_split_markdown_pages` les reconstruisait a l'heuristique, en coupant sur les
8
+ lignes de trois tirets, et sa propre docstring admettait la limite : « la
9
+ pagination ne devient exacte qu'avec un moteur qui renvoie un numero de page
10
+ explicite ». Resultat mesure sur la base : les 710 articles portaient tous
11
+ `page_number = 1`, et sur les 27 lois, 271 pages de PDF ne donnaient que 27
12
+ pages extraites — couverture moyenne 0,60.
13
+
14
+ Gemini est ce moteur. Mesure sur le Code Minier, dont la sortie LlamaParse est
15
+ en base pour comparaison :
16
+
17
+ densite 2352 caracteres/page contre 2405 — equivalent
18
+ marqueurs de page 1 par page contre 1 pour 69 pages
19
+ lot pages 21 a 32 numerote 21..32 exactement
20
+
21
+ CONTRAT DE SORTIE, impose par `text_chunker.py` et non negociable :
22
+ `<<PAGE:n>>\\n<contenu>` par page, pages jointes par `\\n\\n`, numerotation a
23
+ partir de 1 sur la page PHYSIQUE du PDF. Sans espace apres le deux-points :
24
+ `text_chunker` compile `r'<<PAGE:(\\d+)>>'`, et un `<<PAGE: 12>>` serait
25
+ invisible pour lui tout en etant retire ailleurs.
26
+
27
+ Author: JuriX Team
28
+ """
29
+
30
+ import asyncio
31
+ import hashlib
32
+ import io
33
+ import json
34
+ import logging
35
+ import time
36
+ from functools import lru_cache
37
+ from pathlib import Path
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ from google import genai
41
+ from google.genai import types
42
+
43
+ from app.core.config import settings
44
+ from app.services.gemini_service import (
45
+ _finish_reason,
46
+ _is_overloaded,
47
+ _is_quota_exhausted,
48
+ _visible_text,
49
+ retry_after_seconds,
50
+ )
51
+ from app.utils.markdown_cleanup import strip_stamp_blocks
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ class PdfExtractionError(Exception):
57
+ """
58
+ Extraction impossible.
59
+
60
+ Son `str()` atterrit tel quel dans `laws.processing_error`, colonne visible
61
+ par l'administrateur : le message doit se lire, pas se decoder.
62
+ """
63
+
64
+
65
+ class PdfExtractionQuotaError(PdfExtractionError):
66
+ """
67
+ Quota de generation epuise chez le fournisseur.
68
+
69
+ Type distinct pour que les scripts d'ingestion s'arretent proprement et
70
+ reprennent le lendemain, au lieu de compter le document comme un echec
71
+ definitif. Sous-classe de PdfExtractionError pour que le pipeline, lui,
72
+ n'ait rien a savoir de cette nuance.
73
+ """
74
+
75
+
76
+ class PdfExtractionRefusedError(PdfExtractionError):
77
+ """
78
+ Le modele refuse de transcrire une page, la jugeant recitee.
79
+
80
+ Gemini rend `finish_reason=RECITATION` sur certaines pages d'actes officiels
81
+ — vraisemblablement celles dont la forme lui est familiere. Mesure sur un
82
+ echantillon de 7 documents : UNE page de garde refusee, soit environ 14 %.
83
+
84
+ Ce n'est PAS transitoire : la meme page refuse a l'identique, quelle que
85
+ soit la formulation de la consigne (trois variantes essayees, toutes
86
+ refusees) et quelle que soit la taille du lot. En revanche c'est PAR PAGE :
87
+ dans le document mesure, la page 1 refuse et les pages 2 et 3 s'extraient
88
+ normalement.
89
+ """
90
+
91
+
92
+ # Consigne d'extraction. Ecrite en francais parce que le corpus l'est, et
93
+ # formulee en regles numerotees : les consignes en prose donnaient des
94
+ # preambules du genre « Voici la transcription du document ».
95
+ _CONSIGNE = """Transcris ce document juridique en Markdown, fidelement et integralement.
96
+
97
+ {pagination}
98
+
99
+ Regles :
100
+ 1. Commence CHAQUE page par une ligne `<<PAGE:n>>` seule, sans espace apres le deux-points.
101
+ 2. Conserve les numeros d'article exactement tels qu'ils apparaissent, en debut de ligne.
102
+ 3. Restitue les tableaux en Markdown, sans en omettre de ligne.
103
+ 4. N'ajoute aucun commentaire, aucune introduction, aucune conclusion, aucune note.
104
+ 5. Ignore les filigranes, les cachets et les mentions de copie certifiee conforme.
105
+ 6. Si une page est vide, emets quand meme son marqueur, suivi de rien."""
106
+
107
+
108
+ class GeminiPdfExtractor:
109
+ """
110
+ Extracteur PDF -> markdown pagine.
111
+
112
+ Interface volontairement identique a celle de l'ancien service : le
113
+ pipeline (`app/tasks/process_law.py:_extract_pdf_text`) ne fait que
114
+ `is_available()` puis `await extract_text(path)`, et convertit toute
115
+ exception en `status='refused'` + `processing_error`.
116
+ """
117
+
118
+ CACHE_SCHEMA = 3
119
+
120
+ # Nombre de tentatives sur une saturation passagere (503). Le palier gratuit
121
+ # en rend par salves de quelques secondes ; un 429, lui, n'est JAMAIS
122
+ # retente — cela brulerait des appels sur un quota deja epuise.
123
+ OVERLOAD_MAX_ATTEMPTS = 3
124
+ OVERLOAD_BASE_DELAY_S = 2.0
125
+
126
+ # Budget de sortie par appel. La limite du modele est de 65 536 jetons ;
127
+ # a ~2400 caracteres par page, un lot de 20 pages produit ~48 Ko, soit
128
+ # ~13 000 jetons. La marge couvre la reflexion interne du modele.
129
+ MAX_OUTPUT_TOKENS = 60_000
130
+
131
+ def __init__(
132
+ self,
133
+ api_key: Optional[str] = None,
134
+ model_name: Optional[str] = None,
135
+ pages_per_call: Optional[int] = None,
136
+ cache_dir: Optional[Path] = None,
137
+ ):
138
+ # `if api_key is None` et non `api_key or ...` : une chaine vide passee
139
+ # EXPLICITEMENT veut dire « pas de cle », et retombait sinon sur le
140
+ # reglage global — rendant intestable le cas du service non configure.
141
+ self.api_key = settings.GEMINI_API_KEY if api_key is None else api_key
142
+ self.model_name = model_name or settings.GEMINI_MODEL
143
+ self.pages_per_call = pages_per_call or settings.PDF_EXTRACTION_PAGES_PER_CALL
144
+
145
+ self.cache_dir: Optional[Path] = Path(
146
+ cache_dir or getattr(settings, "OCR_CACHE_DIR", "./data/ocr_cache")
147
+ )
148
+ try:
149
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
150
+ except OSError as e:
151
+ logger.warning(f"⚠️ Cache d'extraction indisponible ({e}), desactive")
152
+ self.cache_dir = None
153
+
154
+ # Client construit paresseusement : `is_available()` doit pouvoir
155
+ # repondre sans cle, et le pipeline l'interroge avant tout travail.
156
+ self._client: Optional[genai.Client] = None
157
+
158
+ # Pages que le modele a refuse de transcrire lors de la derniere
159
+ # extraction. Lue par le script de re-extraction pour signaler les
160
+ # documents incomplets plutot que de les faire passer pour entiers.
161
+ self.pages_refusees: List[int] = []
162
+
163
+ # ==================== SURFACE PUBLIQUE ====================
164
+
165
+ def is_available(self) -> bool:
166
+ return bool(self.api_key)
167
+
168
+ async def extract_text(self, file_path: Path) -> str:
169
+ """
170
+ Rend le markdown pagine du PDF.
171
+
172
+ Raises:
173
+ PdfExtractionError: extraction impossible
174
+ PdfExtractionQuotaError: quota du fournisseur epuise
175
+ """
176
+ pages = await self.extract_pages(file_path)
177
+ if not pages:
178
+ raise PdfExtractionError(f"Aucune page extraite de {file_path.name}")
179
+
180
+ # Numerotation AVANT filtrage : une page blanche consomme son numero,
181
+ # ce qui garde les suivantes alignees sur la page physique du PDF.
182
+ parts = [f"<<PAGE:{i}>>\n{md}" for i, md in enumerate(pages, start=1) if md.strip()]
183
+ texte = "\n\n".join(parts)
184
+ logger.info(
185
+ f"✅ Gemini : {len(pages)} page(s), {len(texte)} caracteres "
186
+ f"depuis {file_path.name}"
187
+ )
188
+ return texte
189
+
190
+ async def extract_pages(self, file_path: Path) -> List[str]:
191
+ """Markdown page par page, index 0 = page 1 du PDF."""
192
+ if not self.is_available():
193
+ raise PdfExtractionError(
194
+ "GEMINI_API_KEY absente : extraction impossible. "
195
+ "Aucun repli degrade n'est utilise."
196
+ )
197
+ if not file_path.exists():
198
+ raise PdfExtractionError(f"Fichier introuvable: {file_path}")
199
+
200
+ # Remis a zero a chaque document : cette liste decrit CE document.
201
+ self.pages_refusees: List[int] = []
202
+
203
+ cle = await asyncio.to_thread(self._sha256, file_path)
204
+ en_cache = self._read_cache(cle)
205
+ if en_cache is not None:
206
+ logger.info(f"♻️ Cache d'extraction : {file_path.name} ({len(en_cache)} page(s))")
207
+ return en_cache
208
+
209
+ lots = await asyncio.to_thread(self._decouper_en_lots, file_path)
210
+ pages: List[str] = []
211
+ for premiere, octets in lots:
212
+ try:
213
+ pages.extend(await self._extraire_lot(octets, premiere, file_path.name))
214
+ except PdfExtractionRefusedError:
215
+ # Le refus est PAR PAGE, pas par document : mesure sur un
216
+ # document reel, la page 1 refuse et les pages 2 et 3 passent.
217
+ # On rejoue donc le lot page par page pour ne perdre que ce qui
218
+ # est reellement refuse.
219
+ logger.warning(
220
+ f"⚠️ Lot refuse a partir de la page {premiere}, reprise page par page"
221
+ )
222
+ pages.extend(
223
+ await self._extraire_lot_page_par_page(
224
+ file_path, premiere, len(octets), file_path.name
225
+ )
226
+ )
227
+
228
+ pages = [strip_stamp_blocks(p) for p in pages]
229
+ self._write_cache(cle, pages)
230
+ return pages
231
+
232
+ async def _extraire_lot_page_par_page(
233
+ self, file_path: Path, premiere_page: int, _taille: int, nom: str
234
+ ) -> List[str]:
235
+ """
236
+ Rejoue un lot refuse, une page a la fois.
237
+
238
+ Une page qui refuse encore est rendue VIDE plutot que de faire echouer
239
+ tout le document : perdre une page de garde vaut mieux que perdre les
240
+ soixante-huit autres. Le refus est journalise en ERREUR — il ne doit pas
241
+ se noyer — et la liste des pages perdues remonte a l'appelant, qui
242
+ decide.
243
+ """
244
+ pages_du_lot = await asyncio.to_thread(
245
+ self._pages_du_lot, file_path, premiere_page
246
+ )
247
+ resultat: List[str] = []
248
+ perdues: List[int] = []
249
+
250
+ for decalage, octets in enumerate(pages_du_lot):
251
+ numero = premiere_page + decalage
252
+ try:
253
+ extrait = await self._extraire_lot(octets, numero, nom)
254
+ resultat.extend(extrait)
255
+ except PdfExtractionRefusedError:
256
+ logger.error(f"❌ Page {numero} de {nom} refusee : contenu perdu")
257
+ perdues.append(numero)
258
+ resultat.append("")
259
+
260
+ if perdues:
261
+ self.pages_refusees.extend(perdues)
262
+ return resultat
263
+
264
+ def _pages_du_lot(self, file_path: Path, premiere_page: int) -> List[bytes]:
265
+ """Chaque page du lot, isolee, en memoire."""
266
+ from pypdf import PdfReader, PdfWriter
267
+
268
+ lecteur = PdfReader(str(file_path))
269
+ debut = premiere_page - 1
270
+ fin = min(debut + self.pages_per_call, len(lecteur.pages))
271
+ sortie = []
272
+ for index in range(debut, fin):
273
+ ecrivain = PdfWriter()
274
+ ecrivain.add_page(lecteur.pages[index])
275
+ tampon = io.BytesIO()
276
+ ecrivain.write(tampon)
277
+ sortie.append(tampon.getvalue())
278
+ return sortie
279
+
280
+ async def health_check(self) -> Dict[str, Any]:
281
+ if not self.is_available():
282
+ return {"service": "GeminiPdfExtractor", "status": "unconfigured"}
283
+ return {
284
+ "service": "GeminiPdfExtractor",
285
+ "status": "healthy",
286
+ "model": self.model_name,
287
+ "pages_per_call": self.pages_per_call,
288
+ "cache_dir": str(self.cache_dir) if self.cache_dir else None,
289
+ }
290
+
291
+ def compter_appels(self, file_path: Path) -> int:
292
+ """
293
+ Nombre d'appels que coutera ce document, cache non compris.
294
+
295
+ Sert au `--dry-run` du script de ré-extraction : sur un palier limite a
296
+ 20 appels par jour, savoir AVANT de commencer vaut mieux que decouvrir
297
+ a mi-parcours.
298
+ """
299
+ total = self._compter_pages(file_path)
300
+ return (total + self.pages_per_call - 1) // self.pages_per_call
301
+
302
+ # ==================== APPEL AU MODELE ====================
303
+
304
+ @property
305
+ def client(self) -> genai.Client:
306
+ if self._client is None:
307
+ if not self.api_key:
308
+ raise PdfExtractionError("GEMINI_API_KEY absente")
309
+ # Meme delai que les autres services Gemini : sans http_options, un
310
+ # appel peut bloquer indefiniment sur une lecture de socket. Un
311
+ # incident de quarante minutes l'a etabli.
312
+ self._client = genai.Client(
313
+ api_key=self.api_key,
314
+ http_options=types.HttpOptions(timeout=settings.GEMINI_TIMEOUT_S * 1000),
315
+ )
316
+ return self._client
317
+
318
+ async def _extraire_lot(
319
+ self, octets: bytes, premiere_page: int, nom: str
320
+ ) -> List[str]:
321
+ """Envoie un lot de pages et rend son markdown, page par page."""
322
+ if premiere_page == 1:
323
+ pagination = "Ce document commence a sa premiere page."
324
+ else:
325
+ # SANS cette phrase, le modele renumerote chaque lot a partir de 1
326
+ # et chaque lot ecrase la pagination du precedent. Verifie : lot des
327
+ # pages 21 a 32 rendu « 21..32 » avec la phrase, « 1..12 » sans.
328
+ pagination = (
329
+ f"ATTENTION : ces pages sont un EXTRAIT. La premiere page fournie "
330
+ f"est la page {premiere_page} du document complet. Numerote donc "
331
+ f"les marqueurs a partir de {premiere_page}, en numerotation ABSOLUE."
332
+ )
333
+
334
+ contenu = [
335
+ types.Part.from_bytes(data=octets, mime_type="application/pdf"),
336
+ _CONSIGNE.format(pagination=pagination),
337
+ ]
338
+ config = types.GenerateContentConfig(
339
+ max_output_tokens=self.MAX_OUTPUT_TOKENS,
340
+ # Une transcription n'a pas a etre creative. La temperature nulle
341
+ # rend aussi le resultat reproductible d'un appel a l'autre, ce qui
342
+ # est indispensable pour comparer deux extractions.
343
+ temperature=0.0,
344
+ )
345
+
346
+ for essai in range(1, self.OVERLOAD_MAX_ATTEMPTS + 1):
347
+ try:
348
+ # client.aio : surface asynchrone native du SDK. La surface
349
+ # synchrone bloquerait la boucle pendant tout l'aller-retour,
350
+ # soit des dizaines de secondes par lot.
351
+ reponse = await self.client.aio.models.generate_content(
352
+ model=self.model_name, contents=contenu, config=config
353
+ )
354
+ break
355
+ except Exception as err:
356
+ if _is_quota_exhausted(err):
357
+ delai = retry_after_seconds(err)
358
+ raise PdfExtractionQuotaError(
359
+ f"Quota d'extraction epuise. Reprise possible dans {delai} secondes."
360
+ ) from err
361
+ if not _is_overloaded(err) or essai == self.OVERLOAD_MAX_ATTEMPTS:
362
+ raise PdfExtractionError(
363
+ f"Extraction de {nom} impossible : {err}"
364
+ ) from err
365
+ attente = self.OVERLOAD_BASE_DELAY_S * (2 ** (essai - 1))
366
+ logger.warning(
367
+ f"⚠️ Modele sature (essai {essai}/{self.OVERLOAD_MAX_ATTEMPTS}), "
368
+ f"nouvelle tentative dans {attente:.1f}s"
369
+ )
370
+ await asyncio.sleep(attente)
371
+
372
+ # `_visible_text` et non `reponse.text` : sur un modele a raisonnement,
373
+ # `.text` concatene la reflexion interne avec la reponse.
374
+ texte = _visible_text(reponse)
375
+ if not texte.strip():
376
+ raison = _finish_reason(reponse)
377
+ if raison == "RECITATION":
378
+ raise PdfExtractionRefusedError(
379
+ f"Le modele refuse de transcrire {nom}, pages {premiere_page} et "
380
+ f"suivantes (recitation)."
381
+ )
382
+ raise PdfExtractionError(
383
+ f"Reponse vide du modele sur {nom}, pages a partir de "
384
+ f"{premiere_page} (finish_reason={raison})"
385
+ )
386
+ return self._decouper_par_marqueur(texte, premiere_page)
387
+
388
+ @staticmethod
389
+ def _decouper_par_marqueur(texte: str, premiere_page: int) -> List[str]:
390
+ """
391
+ Separe le markdown sur les marqueurs `<<PAGE:n>>`.
392
+
393
+ Le marqueur lui-meme est RETIRE : `extract_text` le repose ensuite a
394
+ partir de la position dans la liste. C'est ce qui garantit que la
395
+ numerotation finale est continue meme si le modele en saute un.
396
+ """
397
+ import re
398
+
399
+ morceaux = re.split(r"<<PAGE:\s*(\d+)\s*>>", texte)
400
+ if len(morceaux) < 3:
401
+ # Aucun marqueur : le lot entier compte pour une page. Mieux vaut
402
+ # une page unique qu'une exception — le contenu, lui, est bon.
403
+ return [texte.strip()]
404
+
405
+ pages: Dict[int, str] = {}
406
+ for i in range(1, len(morceaux) - 1, 2):
407
+ numero = int(morceaux[i])
408
+ pages[numero] = morceaux[i + 1].strip()
409
+
410
+ # Reconstitution dense : de la premiere page annoncee au maximum vu,
411
+ # les numeros manquants deviennent des pages vides plutot que des trous.
412
+ if not pages:
413
+ return [texte.strip()]
414
+ fin = max(pages)
415
+ return [pages.get(n, "") for n in range(premiere_page, fin + 1)]
416
+
417
+ # ==================== DECOUPAGE DU PDF ====================
418
+
419
+ @staticmethod
420
+ def _compter_pages(file_path: Path) -> int:
421
+ from pypdf import PdfReader
422
+
423
+ try:
424
+ return len(PdfReader(str(file_path)).pages)
425
+ except Exception as e:
426
+ raise PdfExtractionError(f"PDF illisible ({file_path.name}) : {e}") from e
427
+
428
+ def _decouper_en_lots(self, file_path: Path) -> List[tuple]:
429
+ """
430
+ Rend [(numero_de_premiere_page, octets_du_lot), ...].
431
+
432
+ Decoupage EN MEMOIRE : l'ancien service ecrivait des fichiers
433
+ temporaires a cote de l'original et les effacait dans un `finally`. Un
434
+ `BytesIO` ne laisse rien a nettoyer et ne peut pas polluer le repertoire
435
+ d'upload si le processus meurt.
436
+ """
437
+ from pypdf import PdfReader, PdfWriter
438
+
439
+ lecteur = PdfReader(str(file_path))
440
+ total = len(lecteur.pages)
441
+ if total <= self.pages_per_call:
442
+ return [(1, file_path.read_bytes())]
443
+
444
+ lots = []
445
+ for debut in range(0, total, self.pages_per_call):
446
+ ecrivain = PdfWriter()
447
+ for page in lecteur.pages[debut:debut + self.pages_per_call]:
448
+ ecrivain.add_page(page)
449
+ tampon = io.BytesIO()
450
+ ecrivain.write(tampon)
451
+ lots.append((debut + 1, tampon.getvalue()))
452
+ logger.info(
453
+ f"✂️ {file_path.name} : {total} pages -> {len(lots)} lot(s) "
454
+ f"de {self.pages_per_call}"
455
+ )
456
+ return lots
457
+
458
+ # ==================== CACHE SHA256 ====================
459
+
460
+ @staticmethod
461
+ def _sha256(file_path: Path) -> str:
462
+ h = hashlib.sha256()
463
+ with file_path.open("rb") as fh:
464
+ for block in iter(lambda: fh.read(1024 * 1024), b""):
465
+ h.update(block)
466
+ return h.hexdigest()
467
+
468
+ def _cache_path(self, key: str) -> Optional[Path]:
469
+ return self.cache_dir / f"{key}.json" if self.cache_dir else None
470
+
471
+ def _read_cache(self, key: str) -> Optional[List[str]]:
472
+ path = self._cache_path(key)
473
+ if not path or not path.exists():
474
+ return None
475
+ try:
476
+ payload = json.loads(path.read_text(encoding="utf-8"))
477
+ # `engine` remplace l'ancien `tier` : une entree produite par un
478
+ # autre moteur — ou par un autre modele — n'est pas la meme
479
+ # extraction, et surtout pas la meme pagination.
480
+ if payload.get("engine") != self.model_name:
481
+ return None
482
+ if payload.get("schema") != self.CACHE_SCHEMA:
483
+ return None
484
+
485
+ pages = payload.get("pages")
486
+ # Une liste vide comptait autrefois comme un succes : une extraction
487
+ # ratee se gravait et se rejouait indefiniment, et le seul moyen
488
+ # d'en sortir etait d'effacer le fichier a la main.
489
+ if not pages:
490
+ logger.warning("⚠️ Entree de cache vide, ignoree")
491
+ return None
492
+ return pages
493
+ except Exception as e:
494
+ logger.warning(f"⚠️ Cache d'extraction illisible ({e}), ignore")
495
+ return None
496
+
497
+ def _write_cache(self, key: str, pages: List[str]) -> None:
498
+ path = self._cache_path(key)
499
+ if not path:
500
+ return
501
+ # Ne jamais graver un echec.
502
+ if not any(p.strip() for p in pages):
503
+ logger.warning("⚠️ Extraction vide, non mise en cache")
504
+ return
505
+ try:
506
+ # Ecriture atomique : un plantage en cours d'ecriture laissait un
507
+ # JSON tronque qui occupait la place de l'entree valide.
508
+ tmp = path.with_suffix(".json.tmp")
509
+ tmp.write_text(
510
+ json.dumps(
511
+ {
512
+ "schema": self.CACHE_SCHEMA,
513
+ "engine": self.model_name,
514
+ "pages": pages,
515
+ "cached_at": time.time(),
516
+ },
517
+ ensure_ascii=False,
518
+ ),
519
+ encoding="utf-8",
520
+ )
521
+ tmp.replace(path)
522
+ except OSError as e:
523
+ logger.warning(f"⚠️ Ecriture du cache d'extraction echouee ({e})")
524
+
525
+
526
+ @lru_cache()
527
+ def get_pdf_extractor() -> GeminiPdfExtractor:
528
+ """Instance partagee. Le client Gemini et le cache disque sont reutilises."""
529
+ return GeminiPdfExtractor()
app/tasks/process_law.py CHANGED
@@ -294,44 +294,53 @@ def _ingest_file_content(law_id: int, law, file_id: str):
294
 
295
  def _extract_pdf_text(file_path) -> tuple:
296
  """
297
- Extrait le texte d'un PDF via LlamaParse. Aucun repli degrade.
298
 
299
- Le repli precedent — LlamaParse -> Tesseract -> pypdf — etait dangereux :
300
- pypdf renvoie la couche texte deja presente dans les PDF prc.cm, mesuree a
301
- ~20% de rappel (filigrane injecte au milieu des phrases, cachets lus comme
302
- du charabia, 21% des documents sans aucun texte exploitable). Le document
303
- etait alors publie avec ce contenu et marque "completed", sans aucun signal.
 
304
 
305
- Desormais : LlamaParse fait ses 4 tentatives avec backoff exponentiel ; s'il
306
- echoue, on leve. L'appelant marque le document "refused" avec l'erreur, et
307
- il reste retraitable. Rien de degrade n'entre dans l'index.
 
308
 
309
  Returns:
310
- (texte, []) — la liste d'erreurs est conservee pour compatibilite d'appel
 
 
311
 
312
  Raises:
313
- LlamaParseError: extraction impossible
314
  """
315
 
316
- from app.services.llama_parse_service import (
317
- LlamaParseError,
318
- get_llama_parse_service,
319
  )
320
 
321
- # get_llama_parse_service() et non LlamaParseService() : le singleton
322
- # lru_cache existait mais etait contourne par une construction directe,
323
- # ce qui recreait le client et perdait le cache a chaque document.
324
- service = get_llama_parse_service()
325
  if not service.is_available():
326
- raise LlamaParseError(
327
- "LLAMA_CLOUD_API_KEY absente : extraction impossible. "
328
  "Aucun repli degrade n'est utilise."
329
  )
330
 
331
- logger.info(f"📄 Extraction LlamaParse : {file_path.name}")
332
  text = _run_blocking(service.extract_text(file_path))
333
- logger.info(f"✅ LlamaParse : {len(text)} caracteres")
334
- return text, []
 
 
 
 
 
 
 
 
335
 
336
 
337
  def _extract_docx_text(file_path) -> tuple:
 
294
 
295
  def _extract_pdf_text(file_path) -> tuple:
296
  """
297
+ Extrait le texte d'un PDF via Gemini multimodal. Aucun repli degrade.
298
 
299
+ Le repli LlamaParse -> Tesseract -> pypdf a ete supprime avant celui-ci, et
300
+ la raison tient toujours : pypdf renvoie la couche texte deja presente dans
301
+ les PDF prc.cm, mesuree a ~20% de rappel (filigrane injecte au milieu des
302
+ phrases, cachets lus comme du charabia, 21% des documents sans aucun texte
303
+ exploitable). Le document etait alors publie avec ce contenu et marque
304
+ "completed", sans aucun signal.
305
 
306
+ Le moteur a change pour la PAGINATION. LlamaParse ne signalait pas ses
307
+ coupures de page : les 710 articles en base portaient tous
308
+ `page_number = 1`, et 271 pages de PDF ne donnaient que 27 pages extraites.
309
+ Gemini rend un numero de page explicite, verifie exact sur un lot median.
310
 
311
  Returns:
312
+ (texte, erreurs) — `erreurs` nomme les pages que le modele a refuse de
313
+ transcrire, le cas echeant. Le document reste publie sans elles : perdre
314
+ une page de garde vaut mieux que perdre les soixante-huit autres.
315
 
316
  Raises:
317
+ PdfExtractionError: extraction impossible
318
  """
319
 
320
+ from app.services.pdf_extraction_service import (
321
+ PdfExtractionError,
322
+ get_pdf_extractor,
323
  )
324
 
325
+ service = get_pdf_extractor()
 
 
 
326
  if not service.is_available():
327
+ raise PdfExtractionError(
328
+ "GEMINI_API_KEY absente : extraction impossible. "
329
  "Aucun repli degrade n'est utilise."
330
  )
331
 
332
+ logger.info(f"📄 Extraction Gemini : {file_path.name}")
333
  text = _run_blocking(service.extract_text(file_path))
334
+ logger.info(f"✅ Gemini : {len(text)} caracteres")
335
+
336
+ erreurs = []
337
+ if service.pages_refusees:
338
+ erreurs.append(
339
+ "Pages non transcrites (refus du modele) : "
340
+ + ", ".join(str(n) for n in service.pages_refusees)
341
+ )
342
+ logger.error(f"❌ {file_path.name} : {erreurs[0]}")
343
+ return text, erreurs
344
 
345
 
346
  def _extract_docx_text(file_path) -> tuple:
app/utils/markdown_cleanup.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nettoyage du markdown produit par l'extraction de PDF.
3
+
4
+ Ces regles ne dependent d'AUCUN fournisseur : elles decrivent ce que le corpus
5
+ prc.cm contient et qu'il ne faut pas indexer. Elles vivaient dans
6
+ `llama_parse_service.py` et auraient disparu avec lui ; elles ont ete calibrees
7
+ sur ce corpus et servent tout autant a l'extraction par Gemini.
8
+
9
+ Trois regles, chacune payee par une observation :
10
+
11
+ - Le cachet « COPIE CERTIFIEE CONFORME » est appose sur CHAQUE page. Restitue en
12
+ texte, il pollue le plein texte et les vecteurs de tous les documents.
13
+ - Le filigrane diagonal `www.prc.cm` ressort eclate caractere par caractere.
14
+ - `ARTICLE 1<sup>ER</sup>` doit redevenir `ARTICLE 1ER`, faute de quoi
15
+ `text_chunker.ARTICLE_PATTERNS` ne reconnait pas le marqueur — mais `<table>`
16
+ n'est JAMAIS retire : les annexes budgetaires portent leur sens dans leur
17
+ structure tabulaire.
18
+
19
+ Author: JuriX Team
20
+ """
21
+
22
+ import re
23
+
24
+ # Vocabulaire du cachet "COPIE CERTIFIEE CONFORME" appose sur chaque page du
25
+ # corpus prc.cm. L'extracteur le restitue en texte, il faut donc le retirer avant chunking sinon il pollue le FTS
26
+ # et les embeddings de chaque document.
27
+ _STAMP_VOCAB = re.compile(
28
+ r"(copie\s+certifi\w*\s+conforme"
29
+ r"|certified\s+true\s+copy"
30
+ r"|service\s+du\s+fichier\s+l[ée]gislatif"
31
+ r"|legislative\s+and\s+statutory\s+affairs"
32
+ r"|presidency\s+of\s+the\s+republic"
33
+ r"|secr[ée]tariat[\s-]+g[ée]n[ée]ral"
34
+ r"|pr[ée]sidence\s+de\s+la\s+r[ée]publique)",
35
+ re.IGNORECASE,
36
+ )
37
+
38
+ # Fragments du filigrane diagonal "www.prc.cm" eclate par l'OCR
39
+ _WATERMARK = re.compile(
40
+ r"^\s*(?:w\s*){1,3}$|^\s*\.?\s*(?:p\s*r\s*c|c\s*m|prc\.cm|www\.prc\.cm)\s*\.?\s*$",
41
+ re.IGNORECASE,
42
+ )
43
+
44
+ # Balisage HTML inline emis par l'extracteur (les <table> sont conserves : la
45
+ # structure tabulaire porte du sens, cf. les annexes budgetaires).
46
+ # <sup>/<sub> sont critiques : "ARTICLE 1<sup>ER</sup>" doit redevenir
47
+ # "ARTICLE 1ER" pour que ARTICLE_PATTERNS de text_chunker le reconnaisse.
48
+ _INLINE_TAGS = re.compile(
49
+ r"</?(?:u|mark|b|i|em|strong|span|sup|sub|small)\b[^>]*>", re.IGNORECASE
50
+ )
51
+
52
+
53
+ def strip_stamp_blocks(text: str) -> str:
54
+ """
55
+ Retire les blocs de cachet officiel du markdown.
56
+
57
+ Un bloc = au moins 2 lignes consecutives (blancs autorises) appartenant au
58
+ vocabulaire du cachet. Le seuil de 2 evite de supprimer un en-tete legitime :
59
+ beaucoup de documents portent "PRESIDENCE DE LA REPUBLIQUE" comme autorite
60
+ emettrice, ce qui est du contenu, pas du tampon.
61
+
62
+ Args:
63
+ text: Markdown brut d'extraction
64
+
65
+ Returns:
66
+ Markdown nettoye
67
+ """
68
+ lines = text.splitlines()
69
+ drop = [False] * len(lines)
70
+ i = 0
71
+
72
+ while i < len(lines):
73
+ if not _STAMP_VOCAB.search(lines[i]):
74
+ i += 1
75
+ continue
76
+
77
+ # Cas 1 : cachet entier restitue sur une seule ligne, par ex.
78
+ # "[signature: PRESIDENCE ... COPIE CERTIFIEE CONFORME CERTIFIED TRUE COPY]"
79
+ # ou "stamp: ..." / "logo: ...". Deux phrases distinctes du vocabulaire sur
80
+ # la meme ligne ne peuvent pas etre du texte juridique legitime.
81
+ if len(set(m.group(0).lower() for m in _STAMP_VOCAB.finditer(lines[i]))) >= 2:
82
+ drop[i] = True
83
+ i += 1
84
+ continue
85
+
86
+ # Etendre le bloc tant qu'on reste dans le vocabulaire du cachet
87
+ j = i
88
+ hits = 0
89
+ while j < len(lines):
90
+ stripped = lines[j].strip(" >*|-\t")
91
+ if _STAMP_VOCAB.search(lines[j]):
92
+ hits += 1
93
+ j += 1
94
+ elif not stripped:
95
+ j += 1
96
+ else:
97
+ break
98
+
99
+ if hits >= 2:
100
+ for k in range(i, j):
101
+ drop[k] = True
102
+ i = max(j, i + 1)
103
+
104
+ kept = [ln for ln, d in zip(lines, drop) if not d]
105
+ kept = [ln for ln in kept if not _WATERMARK.match(ln)]
106
+ cleaned = "\n".join(kept)
107
+ cleaned = _INLINE_TAGS.sub("", cleaned)
108
+ return re.sub(r"\n{4,}", "\n\n\n", cleaned).strip()
scripts/eval/extraction/baseline.py CHANGED
@@ -18,10 +18,15 @@ CE QU'IL MESURE, et pourquoi chaque mesure est la :
18
  compte d'articles est non nul, et rien ne signale l'echec. Le taux de
19
  PARA_ est donc le vrai indicateur de sante, pas articles_count > 0.
20
 
21
- 2. Le gain latent de normalize_for_chunking(). La fonction existe dans
22
- app/utils/chunk_refiner.py, elle est testee, et elle n'est appelee nulle
23
- part en production. On rejoue la detection de motif sur les caches OCR
24
- deja payes, avec et sans elle, pour chiffrer ce que son cablage rapporte.
 
 
 
 
 
25
 
26
  3. La couverture de pages. _pages_from renvoie une seule page quand la reponse
27
  de LlamaParse est une chaine, alors que le markdown contient les vraies
 
18
  compte d'articles est non nul, et rien ne signale l'echec. Le taux de
19
  PARA_ est donc le vrai indicateur de sante, pas articles_count > 0.
20
 
21
+ 2. Le gain de normalize_for_chunking(). Elle EST desormais appelee en
22
+ production (app/tasks/process_law.py, avant extract_articles) — cette
23
+ docstring affirmait le contraire, ce qui etait vrai a l'ecriture et ne
24
+ l'est plus. On rejoue la detection de motif sur les caches deja payes,
25
+ avec et sans elle, pour chiffrer ce que son cablage rapporte.
26
+
27
+ ATTENTION : la metrique depend de _detect_article_pattern, qui a change
28
+ depuis. Rejouee aujourd'hui sur les MEMES caches, elle rend x1,05 la ou
29
+ phase0 relevait x3,52. Deux mesures ne se comparent qu'au meme commit.
30
 
31
  3. La couverture de pages. _pages_from renvoie une seule page quand la reponse
32
  de LlamaParse est une chaine, alors que le markdown contient les vraies
scripts/maintenance/reextract_corpus.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Re-extrait les documents deja en base avec le moteur Gemini.
4
+
5
+ POURQUOI. L'extraction precedente ne signalait pas les coupures de page : les
6
+ 710 articles portent tous `page_number = 1`, et sur les 27 lois, 271 pages de
7
+ PDF ne donnent que 27 pages extraites — couverture moyenne 0,60. La consequence
8
+ visible est qu'un lien `?article=35` ouvre le PDF a la premiere page.
9
+
10
+ REPRENABLE PAR CONSTRUCTION. Le cache est indexe par sha256 du fichier : un
11
+ document deja extrait n'est jamais repaye, donc relancer le script reprend ou le
12
+ quota l'avait arrete. Sur 429, il s'arrete proprement, dit combien de documents
13
+ restent, et sort en code 2 — ce qui distingue « quota atteint » de « ca a
14
+ plante ».
15
+
16
+ Le palier gratuit plafonne a 20 appels de generation par jour et par modele.
17
+ Un document d'une page coute un appel ; les 72 PDF locaux (481 pages) en
18
+ coutent 83.
19
+
20
+ Usage:
21
+ python scripts/maintenance/reextract_corpus.py --dry-run
22
+ python scripts/maintenance/reextract_corpus.py --apply --max-calls 18
23
+ python scripts/maintenance/reextract_corpus.py --apply --law-id 16
24
+
25
+ Enchainement complet :
26
+ reextract_corpus.py --apply # laws.content recoit les vrais <<PAGE:n>>
27
+ rechunk_laws.py --apply # articles.page_number devient exact
28
+ regenerate_embeddings.py --all --force
29
+
30
+ Codes de sortie:
31
+ 0 termine
32
+ 1 au moins un document en echec
33
+ 2 quota epuise, relancer plus tard
34
+ """
35
+
36
+ import argparse
37
+ import asyncio
38
+ import logging
39
+ import sys
40
+ from pathlib import Path
41
+ from typing import List, Optional
42
+
43
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
44
+
45
+ from app.core.database import SyncSessionLocal
46
+ from app.models.law import Law
47
+ from app.services.pdf_extraction_service import (
48
+ PdfExtractionError,
49
+ PdfExtractionQuotaError,
50
+ get_pdf_extractor,
51
+ )
52
+ from app.utils.file_utils import resolve_upload_path
53
+
54
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
55
+ logger = logging.getLogger("reextract")
56
+
57
+
58
+ def build_parser() -> argparse.ArgumentParser:
59
+ parser = argparse.ArgumentParser(
60
+ description="Re-extrait les documents en base avec Gemini.",
61
+ )
62
+ mode = parser.add_mutually_exclusive_group()
63
+ mode.add_argument("--dry-run", action="store_true",
64
+ help="n'appelle rien, chiffre le cout en appels (defaut)")
65
+ mode.add_argument("--apply", action="store_true",
66
+ help="re-extrait et ecrit laws.content")
67
+ parser.add_argument("--law-id", type=int, action="append", dest="law_ids",
68
+ help="ne traiter que cette loi (repetable)")
69
+ parser.add_argument("--max-calls", type=int, default=None,
70
+ help="plafond dur d'appels pour cette session — le garde-fou du quota")
71
+ parser.add_argument("--only-multipage", action="store_true",
72
+ help="ignorer les documents d'une seule page")
73
+ return parser
74
+
75
+
76
+ def _documents(session, law_ids: Optional[List[int]]):
77
+ """Rend [(loi, chemin_du_pdf)] pour les lois dont le fichier existe."""
78
+ query = session.query(Law).order_by(Law.id)
79
+ if law_ids:
80
+ query = query.filter(Law.id.in_(law_ids))
81
+
82
+ sortie = []
83
+ for law in query.all():
84
+ if not law.file_id:
85
+ logger.warning(" #%-5s sans file_id, ignoree", law.id)
86
+ continue
87
+ try:
88
+ sortie.append((law, resolve_upload_path(law.file_id)))
89
+ except (ValueError, FileNotFoundError) as exc:
90
+ logger.warning(" #%-5s fichier introuvable (%s)", law.id, exc)
91
+ return sortie
92
+
93
+
94
+ async def _traiter(documents, extracteur, appliquer: bool, max_calls: Optional[int]) -> dict:
95
+ bilan = {"traites": 0, "echecs": 0, "appels": 0, "pages_refusees": 0, "quota": False}
96
+
97
+ for law, chemin in documents:
98
+ cout = extracteur.compter_appels(chemin)
99
+ if max_calls is not None and bilan["appels"] + cout > max_calls:
100
+ logger.info(
101
+ " #%-5s %s appel(s) — plafond de session atteint, arret ici",
102
+ law.id, cout,
103
+ )
104
+ break
105
+
106
+ try:
107
+ texte = await extracteur.extract_text(chemin)
108
+ except PdfExtractionQuotaError as exc:
109
+ logger.error("\n⛔ %s", exc)
110
+ bilan["quota"] = True
111
+ break
112
+ except PdfExtractionError as exc:
113
+ logger.error(" #%-5s ECHEC : %s", law.id, exc)
114
+ bilan["echecs"] += 1
115
+ continue
116
+
117
+ bilan["appels"] += cout
118
+ bilan["traites"] += 1
119
+ refusees = list(extracteur.pages_refusees)
120
+ bilan["pages_refusees"] += len(refusees)
121
+
122
+ marqueurs = texte.count("<<PAGE:")
123
+ detail = f" — pages refusees : {refusees}" if refusees else ""
124
+ logger.info(
125
+ " #%-5s %5d car, %3d page(s), %s appel(s)%s %s",
126
+ law.id, len(texte), marqueurs, cout, detail, (law.title or "")[:38],
127
+ )
128
+
129
+ if appliquer:
130
+ # Une transaction PAR document : un echec en cours de route ne doit
131
+ # pas laisser la moitie du corpus dans un etat intermediaire.
132
+ with SyncSessionLocal() as session:
133
+ ligne = session.get(Law, law.id)
134
+ if ligne:
135
+ ligne.content = texte
136
+ ligne.processing_error = (
137
+ f"Pages non transcrites (refus du modele) : "
138
+ f"{', '.join(str(n) for n in refusees)}"
139
+ if refusees else None
140
+ )
141
+ session.commit()
142
+
143
+ return bilan
144
+
145
+
146
+ def main(argv: Optional[List[str]] = None) -> int:
147
+ args = build_parser().parse_args(argv)
148
+ extracteur = get_pdf_extractor()
149
+
150
+ if not extracteur.is_available():
151
+ logger.error("❌ GEMINI_API_KEY absente : rien a faire.")
152
+ return 1
153
+
154
+ with SyncSessionLocal() as session:
155
+ documents = _documents(session, args.law_ids)
156
+ # Detacher les objets : la session se ferme, et le traitement ouvre la
157
+ # sienne par document.
158
+ documents = [
159
+ (session.merge(law), chemin) for law, chemin in documents
160
+ ]
161
+ session.expunge_all()
162
+
163
+ if args.only_multipage:
164
+ documents = [
165
+ (law, chemin) for law, chemin in documents
166
+ if extracteur._compter_pages(chemin) > 1
167
+ ]
168
+
169
+ total_appels = sum(extracteur.compter_appels(chemin) for _, chemin in documents)
170
+ logger.info("Documents : %d", len(documents))
171
+ logger.info("Appels necessaires : %d (cache non compris)", total_appels)
172
+ logger.info("Soit, a 20 par jour : %.1f jour(s)", total_appels / 20)
173
+ if args.max_calls:
174
+ logger.info("Plafond de session : %d appel(s)", args.max_calls)
175
+ logger.info("%s", "-" * 72)
176
+
177
+ if not args.apply:
178
+ logger.info("(dry-run : aucun appel emis. Relancez avec --apply.)")
179
+ return 0
180
+
181
+ bilan = asyncio.run(_traiter(documents, extracteur, True, args.max_calls))
182
+
183
+ logger.info("%s", "=" * 72)
184
+ logger.info("Documents traites : %d", bilan["traites"])
185
+ logger.info("Appels consommes : %d", bilan["appels"])
186
+ if bilan["pages_refusees"]:
187
+ logger.info("Pages refusees : %d (voir laws.processing_error)",
188
+ bilan["pages_refusees"])
189
+ if bilan["echecs"]:
190
+ logger.error("Echecs : %d", bilan["echecs"])
191
+
192
+ restants = len(documents) - bilan["traites"] - bilan["echecs"]
193
+ if bilan["quota"]:
194
+ logger.info("\n⛔ Quota epuise. %d document(s) restent.", restants)
195
+ logger.info(" Relancez demain : le cache reprend ou il s'est arrete.")
196
+ return 2
197
+ if restants:
198
+ logger.info("\n%d document(s) restent (plafond de session).", restants)
199
+
200
+ logger.info("\nEtape suivante : python scripts/maintenance/rechunk_laws.py --apply")
201
+ logger.info("%s", "=" * 72)
202
+ return 1 if bilan["echecs"] else 0
203
+
204
+
205
+ if __name__ == "__main__":
206
+ sys.exit(main())
tests/test_services/test_llama_parse_service.py DELETED
@@ -1,305 +0,0 @@
1
- """
2
- Tests du service d'extraction LlamaParse.
3
-
4
- Aucun appel réseau : `respx` intercepte httpx. Sans cela ces tests
5
- consommeraient des crédits LlamaParse à chaque exécution.
6
- """
7
-
8
- import json
9
-
10
- import httpx
11
- import pytest
12
- import respx
13
-
14
- from app.services.llama_parse_service import (
15
- LlamaParseError,
16
- LlamaParseService,
17
- _split_markdown_pages,
18
- _strip_stamp_blocks,
19
- )
20
-
21
- BASE = "https://api.cloud.llamaindex.ai/api/v2/parse"
22
-
23
-
24
- @pytest.fixture
25
- def service(tmp_path):
26
- return LlamaParseService(api_key="cle-de-test", cache_dir=tmp_path / "cache")
27
-
28
-
29
- @pytest.fixture
30
- def pdf(tmp_path):
31
- p = tmp_path / "doc.pdf"
32
- p.write_bytes(b"%PDF-1.4\ncontenu\n%%EOF")
33
- return p
34
-
35
-
36
- class TestNettoyageDesCachets:
37
- """
38
- Retrait du cachet officiel apposé sur chaque page du corpus prc.cm.
39
-
40
- LlamaParse restitue ce cachet en texte ; sans nettoyage il pollue le FTS et
41
- les embeddings de tous les documents.
42
- """
43
-
44
- def test_bloc_multiligne_retire(self):
45
- md = (
46
- "Article 1er. Contenu juridique réel.\n\n"
47
- "PRESIDENCE DE LA REPUBLIQUE\n"
48
- "SERVICE DU FICHIER LEGISLATIF ET REGLEMENTAIRE\n"
49
- "COPIE CERTIFIEE CONFORME\n"
50
- "CERTIFIED TRUE COPY\n\n"
51
- "Article 2. Autre contenu."
52
- )
53
- out = _strip_stamp_blocks(md)
54
- assert "COPIE CERTIFIEE CONFORME" not in out
55
- assert "Article 1er. Contenu juridique réel." in out
56
- assert "Article 2. Autre contenu." in out
57
-
58
- def test_cachet_sur_une_seule_ligne_retire(self):
59
- """LlamaParse condense parfois tout le cachet sur une ligne."""
60
- md = (
61
- "Article 1er. Contenu.\n"
62
- "[signature: PRESIDENCE DE LA REPUBLIQUE SECRETARIAT GENERAL "
63
- "COPIE CERTIFIEE CONFORME CERTIFIED TRUE COPY]\n"
64
- )
65
- out = _strip_stamp_blocks(md)
66
- assert "CERTIFIED TRUE COPY" not in out
67
- assert "Article 1er. Contenu." in out
68
-
69
- def test_en_tete_legitime_conserve(self):
70
- """
71
- Beaucoup de décrets portent « PRESIDENCE DE LA REPUBLIQUE » comme
72
- autorité émettrice : c'est du contenu, pas un tampon. Une seule phrase
73
- du vocabulaire ne doit donc rien déclencher.
74
- """
75
- md = "PRESIDENCE DE LA REPUBLIQUE\n\nDECRET N° 2024/191 du 4 juin 2024"
76
- out = _strip_stamp_blocks(md)
77
- assert "PRESIDENCE DE LA REPUBLIQUE" in out
78
- assert "DECRET N° 2024/191" in out
79
-
80
- def test_filigrane_retire(self):
81
- md = "Article 1er.\nw\nw\nw\n.prc\n.cm\nContenu."
82
- out = _strip_stamp_blocks(md)
83
- assert "Article 1er." in out and "Contenu." in out
84
- assert "\nw\n" not in f"\n{out}\n"
85
-
86
- def test_tableaux_conserves(self):
87
- """Les <table> portent du sens (annexes budgétaires) — jamais retirés."""
88
- md = "<table><tr><td>Recettes</td><td>66 900 000</td></tr></table>"
89
- assert "<table>" in _strip_stamp_blocks(md)
90
-
91
- def test_balises_inline_retirees(self):
92
- """<sup> casse la détection d'article : ARTICLE 1<sup>ER</sup>."""
93
- assert _strip_stamp_blocks("**ARTICLE 1<sup>ER</sup>**") == "**ARTICLE 1ER**"
94
-
95
-
96
- class TestDisponibilite:
97
- def test_indisponible_sans_cle(self, tmp_path, monkeypatch):
98
- # api_key="" retombe volontairement sur settings.LLAMA_CLOUD_API_KEY :
99
- # il faut donc neutraliser aussi la configuration pour tester ce cas.
100
- monkeypatch.setattr(
101
- "app.services.llama_parse_service.settings.LLAMA_CLOUD_API_KEY", ""
102
- )
103
- assert not LlamaParseService(api_key="", cache_dir=tmp_path).is_available()
104
-
105
- def test_disponible_avec_cle(self, service):
106
- assert service.is_available()
107
-
108
-
109
- class TestExtraction:
110
- @pytest.mark.asyncio
111
- @respx.mock
112
- async def test_extraction_et_marqueurs_de_page(self, service, pdf):
113
- respx.post(f"{BASE}/upload").mock(
114
- return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
115
- )
116
- respx.get(f"{BASE}/job-1").mock(
117
- return_value=httpx.Response(
118
- 200,
119
- json={
120
- "job": {"status": "COMPLETED"},
121
- "markdown": [{"markdown": "Page une"}, {"markdown": "Page deux"}],
122
- },
123
- )
124
- )
125
- service.POLL_INTERVAL = 0
126
- text = await service.extract_text(pdf)
127
- # Les marqueurs alimentent Article.page_number via text_chunker
128
- assert "<<PAGE:1>>" in text and "<<PAGE:2>>" in text
129
- assert "Page une" in text and "Page deux" in text
130
-
131
- @pytest.mark.asyncio
132
- @respx.mock
133
- async def test_cache_evite_un_second_appel(self, service, pdf):
134
- upload = respx.post(f"{BASE}/upload").mock(
135
- return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
136
- )
137
- respx.get(f"{BASE}/job-1").mock(
138
- return_value=httpx.Response(
139
- 200,
140
- json={"job": {"status": "COMPLETED"}, "markdown": [{"markdown": "Texte"}]},
141
- )
142
- )
143
- service.POLL_INTERVAL = 0
144
- first = await service.extract_text(pdf)
145
- second = await service.extract_text(pdf)
146
- assert first == second
147
- assert upload.call_count == 1, "le second appel aurait dû être servi par le cache"
148
-
149
- @pytest.mark.asyncio
150
- @respx.mock
151
- async def test_job_en_echec_leve(self, service, pdf):
152
- respx.post(f"{BASE}/upload").mock(
153
- return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
154
- )
155
- respx.get(f"{BASE}/job-1").mock(
156
- return_value=httpx.Response(
157
- 200,
158
- json={"job": {"status": "FAILED", "error_message": "document illisible"}},
159
- )
160
- )
161
- service.POLL_INTERVAL = 0
162
- with pytest.raises(LlamaParseError, match="document illisible"):
163
- await service.extract_text(pdf)
164
-
165
- @pytest.mark.asyncio
166
- @respx.mock
167
- async def test_cle_refusee_ne_declenche_pas_de_retry(self, service, pdf):
168
- """Un 401 est définitif : réessayer 4 fois ne ferait que perdre du temps."""
169
- route = respx.post(f"{BASE}/upload").mock(
170
- return_value=httpx.Response(401, text="cle invalide")
171
- )
172
- service.POLL_INTERVAL = 0
173
- with pytest.raises(LlamaParseError):
174
- await service.extract_text(pdf)
175
- assert route.call_count == 1
176
-
177
- @pytest.mark.asyncio
178
- async def test_fichier_absent(self, service, tmp_path):
179
- with pytest.raises(LlamaParseError, match="introuvable"):
180
- await service.extract_text(tmp_path / "absent.pdf")
181
-
182
-
183
- class TestNormalisationDesReponses:
184
- """L'API v2 renvoie le markdown sous plusieurs formes selon les options."""
185
-
186
- def test_liste_de_pages(self):
187
- pages = LlamaParseService._pages_from(
188
- {"markdown": [{"markdown": "a"}, {"markdown": "b"}]}
189
- )
190
- assert pages == ["a", "b"]
191
-
192
- def test_chaine_unique(self):
193
- assert LlamaParseService._pages_from({"markdown": "seule"}) == ["seule"]
194
-
195
- def test_champ_pages(self):
196
- assert LlamaParseService._pages_from({"pages": [{"markdown": "x"}]}) == ["x"]
197
-
198
- def test_markdown_full(self):
199
- assert LlamaParseService._pages_from({"markdown_full": "tout"}) == ["tout"]
200
-
201
- def test_reponse_vide(self):
202
- assert LlamaParseService._pages_from({}) == []
203
-
204
-
205
- class TestDecoupeEnPages:
206
- """
207
- Restitution de la pagination quand la réponse arrive en un seul bloc.
208
-
209
- Sans cette découpe, `_pages_from` renvoyait une page unique pour tout le
210
- document : les 849 articles en base portaient tous `page_number = 1`, alors
211
- que les vraies coupures étaient présentes dans le markdown.
212
-
213
- Le seuil « exactement trois tirets » n'est pas arbitraire : mesuré sur 64
214
- documents appariés à leur PDF (473 pages de vérité terrain), `-{3,}`
215
- donnait 44/64 comptes exacts et 10 sur-découpes, `-{3}` en donne 51/64 et 3.
216
- """
217
-
218
- def test_saut_de_page_decoupe(self):
219
- pages = _split_markdown_pages("Page un.\n\n---\nPage deux.\n\n---\nPage trois.")
220
- assert pages == ["Page un.", "Page deux.", "Page trois."]
221
-
222
- def test_separateur_de_tableau_ne_decoupe_pas(self):
223
- # `|---|---|` contient des barres : la ligne entière est exigée.
224
- markdown = "Avant\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nApres"
225
- assert len(_split_markdown_pages(markdown)) == 1
226
-
227
- def test_tirets_longs_ne_decoupent_pas(self):
228
- # Soulignements et bordures ASCII : du contenu, jamais un saut de page.
229
- assert len(_split_markdown_pages("A\n-----\nB")) == 1
230
-
231
- def test_document_sans_saut_reste_une_page(self):
232
- assert _split_markdown_pages("Un seul bloc.") == ["Un seul bloc."]
233
-
234
- def test_page_vide_interieure_conservee(self):
235
- # `extract_text` numérote AVANT de filtrer : supprimer une page blanche
236
- # ici décalerait tous les numéros suivants.
237
- assert _split_markdown_pages("A\n---\n\n---\nC") == ["A", "", "C"]
238
-
239
- def test_sauts_aux_bords_ne_creent_pas_de_page_vide(self):
240
- assert _split_markdown_pages("\n---\nA\n---\nB\n---\n") == ["A", "B"]
241
-
242
- def test_frontmatter_yaml_ne_decoupe_pas(self):
243
- # Le délimiteur fermant du frontmatter est lui aussi `---`.
244
- assert len(_split_markdown_pages("---\ntitre: x\n---\nCorps unique.")) == 1
245
-
246
- def test_frontmatter_reste_sur_la_premiere_page(self):
247
- pages = _split_markdown_pages("---\nt: x\n---\nPage un\n---\nPage deux")
248
- assert pages == ["---\nt: x\n---\nPage un", "Page deux"]
249
-
250
- def test_texte_vide(self):
251
- assert _split_markdown_pages("") == [""]
252
-
253
- def test_applique_a_markdown_full(self):
254
- assert LlamaParseService._pages_from({"markdown_full": "A\n---\nB"}) == ["A", "B"]
255
-
256
-
257
- class TestIntegriteDuCache:
258
- """
259
- Le cache ne doit jamais graver un échec, ni rejouer un format périmé.
260
-
261
- Une liste vide comptait comme un HIT : une extraction ratée se gravait sur
262
- disque et se rejouait indéfiniment sans jamais rappeler l'API. Le seul moyen
263
- d'en sortir était d'effacer le fichier à la main.
264
- """
265
-
266
- def test_extraction_vide_non_gravee(self, service, tmp_path):
267
- service._write_cache("vide", [])
268
- assert service._cache_path("vide").exists() is False
269
-
270
- def test_pages_blanches_non_gravees(self, service):
271
- service._write_cache("blanc", ["", " "])
272
- assert service._cache_path("blanc").exists() is False
273
-
274
- def test_entree_vide_sur_disque_est_un_miss(self, service):
275
- service._cache_path("poison").write_text(
276
- json.dumps({"schema": service.CACHE_SCHEMA, "tier": service.tier, "pages": []})
277
- )
278
- assert service._read_cache("poison") is None
279
-
280
- def test_aller_retour(self, service):
281
- service._write_cache("ok", ["page un", "page deux"])
282
- assert service._read_cache("ok") == ["page un", "page deux"]
283
-
284
- def test_cache_v1_redecoupe_sur_place_sans_rappeler_l_api(self, service):
285
- # Le contenu d'une entrée v1 est valide et déjà payé : il est redécoupé
286
- # localement, jamais re-extrait.
287
- service._cache_path("v1").write_text(
288
- json.dumps({"tier": service.tier, "pages": ["Page un\n---\nPage deux"]})
289
- )
290
- assert service._read_cache("v1") == ["Page un", "Page deux"]
291
-
292
- # Et réécrit au format courant, donc redécoupé une seule fois.
293
- payload = json.loads(service._cache_path("v1").read_text())
294
- assert payload["schema"] == service.CACHE_SCHEMA
295
- assert len(payload["pages"]) == 2
296
-
297
- def test_cache_v1_vide_reste_un_miss(self, service):
298
- service._cache_path("v1vide").write_text(
299
- json.dumps({"tier": service.tier, "pages": []})
300
- )
301
- assert service._read_cache("v1vide") is None
302
-
303
- def test_ecriture_atomique_sans_residu(self, service, tmp_path):
304
- service._write_cache("atomique", ["contenu"])
305
- assert list(service.cache_dir.glob("*.tmp")) == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_services/test_pdf_extraction.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extraction des PDF par Gemini.
3
+
4
+ Ces tests figent le CONTRAT que le decoupeur impose, et les trois modes d'echec
5
+ observes en production sur le palier gratuit.
6
+
7
+ Le reseau est simule par monkeypatch sur `client.aio.models.generate_content`,
8
+ et non par respx : respx intercepte httpx, alors que le SDK google-genai a sa
9
+ propre pile — un mock respx ne verrait jamais l'appel.
10
+
11
+ La fixture PDF est un VRAI PDF construit par pypdf. Le talon de 22 octets qui
12
+ suffisait a l'ancien service ne convient plus : celui-ci ouvre le fichier pour
13
+ compter et decouper ses pages.
14
+
15
+ Usage:
16
+ pytest tests/test_services/test_pdf_extraction.py -v
17
+ """
18
+
19
+ import json
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+
23
+ import pytest
24
+
25
+ from app.services.pdf_extraction_service import (
26
+ GeminiPdfExtractor,
27
+ PdfExtractionError,
28
+ PdfExtractionQuotaError,
29
+ )
30
+
31
+ SATURATION = (
32
+ "503 UNAVAILABLE. {'error': {'code': 503, 'message': 'This model is currently "
33
+ "experiencing high demand.', 'status': 'UNAVAILABLE'}}"
34
+ )
35
+ QUOTA = (
36
+ "429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'You exceeded your "
37
+ "current quota', 'details': [{'retryDelay': '42s'}]}}"
38
+ )
39
+
40
+
41
+ def _pdf(chemin: Path, pages: int = 1) -> Path:
42
+ from pypdf import PdfWriter
43
+
44
+ writer = PdfWriter()
45
+ for _ in range(pages):
46
+ writer.add_blank_page(width=200, height=200)
47
+ with chemin.open("wb") as fh:
48
+ writer.write(fh)
49
+ return chemin
50
+
51
+
52
+ def _reponse(texte: str, finish: str = "STOP"):
53
+ """Reponse a la forme de celle du SDK, reflexion comprise si demandee."""
54
+ part = SimpleNamespace(text=texte, thought=False)
55
+ contenu = SimpleNamespace(parts=[part] if texte else [])
56
+ candidat = SimpleNamespace(content=contenu, finish_reason=SimpleNamespace(name=finish))
57
+ return SimpleNamespace(candidates=[candidat], text=texte)
58
+
59
+
60
+ @pytest.fixture
61
+ def extracteur(tmp_path):
62
+ return GeminiPdfExtractor(
63
+ api_key="cle-de-test",
64
+ model_name="modele-test",
65
+ pages_per_call=2,
66
+ cache_dir=tmp_path / "cache",
67
+ )
68
+
69
+
70
+ def _brancher(extracteur, reponses):
71
+ """
72
+ Remplace l'appel au modele par une liste de reponses ou d'exceptions.
73
+
74
+ Rend un compteur d'appels : c'est lui qui prouve qu'un quota epuise n'est
75
+ PAS retente.
76
+ """
77
+ etat = {"appels": 0}
78
+ file = list(reponses)
79
+
80
+ async def _faux(**kwargs):
81
+ etat["appels"] += 1
82
+ item = file.pop(0) if file else file_defaut
83
+ if isinstance(item, Exception):
84
+ raise item
85
+ return item
86
+
87
+ file_defaut = _reponse("<<PAGE:1>>\nContenu.")
88
+ extracteur._client = SimpleNamespace(
89
+ aio=SimpleNamespace(models=SimpleNamespace(generate_content=_faux))
90
+ )
91
+ return etat
92
+
93
+
94
+ class TestContratDeSortie:
95
+ """Ce que `text_chunker` exige, au caractere pres."""
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_marqueur_sans_espace_apres_les_deux_points(self, extracteur, tmp_path):
99
+ """
100
+ `text_chunker.py` compile `r'<<PAGE:(\\d+)>>'`. Un `<<PAGE: 1>>` serait
101
+ invisible pour lui tout en etant retire ailleurs : la page existerait
102
+ dans le texte et pas dans la numerotation.
103
+ """
104
+ _brancher(extracteur, [_reponse("<<PAGE:1>>\nLe contenu.")])
105
+
106
+ texte = await extracteur.extract_text(_pdf(tmp_path / "d.pdf"))
107
+
108
+ assert texte.startswith("<<PAGE:1>>\n")
109
+ assert "<<PAGE: " not in texte
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_pages_jointes_par_deux_sauts_de_ligne(self, extracteur, tmp_path):
113
+ _brancher(extracteur, [_reponse("<<PAGE:1>>\nUn\n<<PAGE:2>>\nDeux")])
114
+
115
+ texte = await extracteur.extract_text(_pdf(tmp_path / "d.pdf", pages=2))
116
+
117
+ assert texte == "<<PAGE:1>>\nUn\n\n<<PAGE:2>>\nDeux"
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_une_page_blanche_ne_decale_pas_les_suivantes(self, extracteur, tmp_path):
121
+ """
122
+ La numerotation se fait AVANT le filtrage des pages vides : une page
123
+ blanche consomme son numero, ce qui garde les suivantes alignees sur la
124
+ page physique du PDF.
125
+ """
126
+ _brancher(extracteur, [_reponse("<<PAGE:1>>\nUn\n<<PAGE:2>>\n\n<<PAGE:3>>\nTrois")])
127
+
128
+ texte = await extracteur.extract_text(_pdf(tmp_path / "d.pdf", pages=3))
129
+
130
+ assert "<<PAGE:1>>\nUn" in texte
131
+ assert "<<PAGE:3>>\nTrois" in texte
132
+ assert "<<PAGE:2>>" not in texte, "la page vide est omise, pas rendue vide"
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_texte_sans_aucun_marqueur_compte_pour_une_page(self, extracteur, tmp_path):
136
+ """Mieux vaut une page unique qu'une exception : le contenu est bon."""
137
+ _brancher(extracteur, [_reponse("Du texte sans le moindre marqueur.")])
138
+
139
+ texte = await extracteur.extract_text(_pdf(tmp_path / "d.pdf"))
140
+
141
+ assert texte == "<<PAGE:1>>\nDu texte sans le moindre marqueur."
142
+
143
+
144
+ class TestNumerotationAbsolue:
145
+ """
146
+ Le point qui casse en silence : sans consigne explicite, le modele
147
+ renumerote chaque lot a partir de 1 et chaque lot ecrase le precedent.
148
+ """
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_la_consigne_annonce_la_premiere_page_du_lot(self, extracteur, tmp_path):
152
+ consignes = []
153
+
154
+ async def _capturer(**kwargs):
155
+ consignes.append(kwargs["contents"][1])
156
+ depart = len(consignes) # lot 1 -> pages 1-2, lot 2 -> pages 3-4
157
+ base = 1 if depart == 1 else 3
158
+ return _reponse(f"<<PAGE:{base}>>\nA\n<<PAGE:{base + 1}>>\nB")
159
+
160
+ extracteur._client = SimpleNamespace(
161
+ aio=SimpleNamespace(models=SimpleNamespace(generate_content=_capturer))
162
+ )
163
+
164
+ await extracteur.extract_text(_pdf(tmp_path / "d.pdf", pages=4))
165
+
166
+ assert len(consignes) == 2, "4 pages en lots de 2 font deux appels"
167
+ assert "premiere page" in consignes[0]
168
+ assert "page 3 du document complet" in consignes[1]
169
+ assert "ABSOLUE" in consignes[1]
170
+
171
+ @pytest.mark.asyncio
172
+ async def test_les_lots_se_recollent_en_numerotation_continue(self, extracteur, tmp_path):
173
+ import re
174
+
175
+ appels = {"n": 0}
176
+
177
+ async def _faux(**kwargs):
178
+ appels["n"] += 1
179
+ base = 1 if appels["n"] == 1 else 3
180
+ return _reponse(f"<<PAGE:{base}>>\nA\n<<PAGE:{base + 1}>>\nB")
181
+
182
+ extracteur._client = SimpleNamespace(
183
+ aio=SimpleNamespace(models=SimpleNamespace(generate_content=_faux))
184
+ )
185
+
186
+ texte = await extracteur.extract_text(_pdf(tmp_path / "d.pdf", pages=4))
187
+
188
+ assert [int(n) for n in re.findall(r"<<PAGE:(\d+)>>", texte)] == [1, 2, 3, 4]
189
+
190
+
191
+ class TestModesDEchec:
192
+ @pytest.mark.asyncio
193
+ async def test_un_quota_epuise_n_est_jamais_retente(self, extracteur, tmp_path):
194
+ """Retenter un quota epuise brule des appels qu'on n'a deja plus."""
195
+ etat = _brancher(extracteur, [Exception(QUOTA)])
196
+
197
+ with pytest.raises(PdfExtractionQuotaError, match="42 secondes"):
198
+ await extracteur.extract_text(_pdf(tmp_path / "d.pdf"))
199
+
200
+ assert etat["appels"] == 1
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_une_saturation_est_retentee_puis_abandonnee(self, extracteur, tmp_path):
204
+ etat = _brancher(extracteur, [Exception(SATURATION)] * 3)
205
+ extracteur.OVERLOAD_BASE_DELAY_S = 0
206
+
207
+ with pytest.raises(PdfExtractionError):
208
+ await extracteur.extract_text(_pdf(tmp_path / "d.pdf"))
209
+
210
+ assert etat["appels"] == 3
211
+
212
+ @pytest.mark.asyncio
213
+ async def test_une_saturation_passagere_est_absorbee(self, extracteur, tmp_path):
214
+ _brancher(extracteur, [Exception(SATURATION), _reponse("<<PAGE:1>>\nOK")])
215
+ extracteur.OVERLOAD_BASE_DELAY_S = 0
216
+
217
+ assert "OK" in await extracteur.extract_text(_pdf(tmp_path / "d.pdf"))
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_un_refus_de_recitation_rejoue_page_par_page(self, extracteur, tmp_path):
221
+ """
222
+ Mesure sur un document reel : la page 1 refuse, les pages 2 et 3
223
+ s'extraient. Refuser tout le document pour une page de garde ferait
224
+ perdre les soixante-huit autres.
225
+ """
226
+ reponses = [
227
+ _reponse("", finish="RECITATION"), # le lot entier
228
+ _reponse("", finish="RECITATION"), # page 1, refusee a nouveau
229
+ _reponse("<<PAGE:2>>\nDeux"), # page 2, elle passe
230
+ ]
231
+ _brancher(extracteur, reponses)
232
+
233
+ texte = await extracteur.extract_text(_pdf(tmp_path / "d.pdf", pages=2))
234
+
235
+ assert "Deux" in texte
236
+ assert extracteur.pages_refusees == [1]
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_une_reponse_vide_sans_raison_connue_leve(self, extracteur, tmp_path):
240
+ _brancher(extracteur, [_reponse("", finish="SAFETY")])
241
+
242
+ with pytest.raises(PdfExtractionError, match="SAFETY"):
243
+ await extracteur.extract_text(_pdf(tmp_path / "d.pdf"))
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_sans_cle_le_service_refuse_de_travailler(self, tmp_path):
247
+ muet = GeminiPdfExtractor(api_key="", cache_dir=tmp_path / "c")
248
+
249
+ assert muet.is_available() is False
250
+ with pytest.raises(PdfExtractionError, match="GEMINI_API_KEY"):
251
+ await muet.extract_text(_pdf(tmp_path / "d.pdf"))
252
+
253
+ @pytest.mark.asyncio
254
+ async def test_fichier_absent(self, extracteur, tmp_path):
255
+ with pytest.raises(PdfExtractionError, match="introuvable"):
256
+ await extracteur.extract_text(tmp_path / "nexiste-pas.pdf")
257
+
258
+
259
+ class TestCache:
260
+ @pytest.mark.asyncio
261
+ async def test_un_second_appel_ne_touche_pas_le_modele(self, extracteur, tmp_path):
262
+ etat = _brancher(extracteur, [_reponse("<<PAGE:1>>\nContenu.")])
263
+ pdf = _pdf(tmp_path / "d.pdf")
264
+
265
+ premier = await extracteur.extract_text(pdf)
266
+ second = await extracteur.extract_text(pdf)
267
+
268
+ assert premier == second
269
+ assert etat["appels"] == 1
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_une_extraction_vide_n_est_jamais_gravee(self, extracteur, tmp_path):
273
+ """
274
+ Une entree vide comptait autrefois comme un succes : l'echec se figeait
275
+ et le seul moyen d'en sortir etait d'effacer le fichier a la main.
276
+ """
277
+ extracteur._write_cache("cle-test", ["", " "])
278
+
279
+ assert list((tmp_path / "cache").glob("*.json")) == []
280
+
281
+ def test_une_entree_vide_sur_disque_est_un_defaut_de_cache(self, extracteur, tmp_path):
282
+ chemin = tmp_path / "cache" / "cle-test.json"
283
+ chemin.write_text(json.dumps(
284
+ {"schema": extracteur.CACHE_SCHEMA, "engine": "modele-test", "pages": []}
285
+ ))
286
+
287
+ assert extracteur._read_cache("cle-test") is None
288
+
289
+ def test_une_entree_d_un_autre_moteur_est_ignoree(self, extracteur, tmp_path):
290
+ """
291
+ Les 61 entrees heritees de LlamaParse portent `tier`, pas `engine` :
292
+ elles ne doivent pas etre resservies comme si elles venaient d'ici — ce
293
+ sont precisement les extractions non paginees qu'on remplace.
294
+ """
295
+ chemin = tmp_path / "cache" / "cle-test.json"
296
+ chemin.write_text(json.dumps(
297
+ {"schema": 2, "tier": "cost_effective", "pages": ["ancien contenu"]}
298
+ ))
299
+
300
+ assert extracteur._read_cache("cle-test") is None
301
+
302
+ def test_ecriture_atomique_sans_residu(self, extracteur, tmp_path):
303
+ extracteur._write_cache("cle-test", ["du contenu"])
304
+
305
+ assert list((tmp_path / "cache").glob("*.tmp")) == []
306
+ assert extracteur._read_cache("cle-test") == ["du contenu"]
307
+
308
+
309
+ class TestDecoupageEtComptage:
310
+ def test_un_document_court_ne_fait_qu_un_lot(self, extracteur, tmp_path):
311
+ pdf = _pdf(tmp_path / "d.pdf", pages=2)
312
+
313
+ assert extracteur.compter_appels(pdf) == 1
314
+ assert len(extracteur._decouper_en_lots(pdf)) == 1
315
+
316
+ def test_un_document_long_est_decoupe(self, extracteur, tmp_path):
317
+ pdf = _pdf(tmp_path / "d.pdf", pages=5)
318
+
319
+ lots = extracteur._decouper_en_lots(pdf)
320
+
321
+ assert extracteur.compter_appels(pdf) == 3
322
+ assert [premiere for premiere, _ in lots] == [1, 3, 5]
323
+
324
+ def test_le_decoupage_ne_laisse_aucun_fichier_temporaire(self, extracteur, tmp_path):
325
+ """
326
+ Decoupage EN MEMOIRE : l'ancien service ecrivait des fichiers a cote de
327
+ l'original et les effacait dans un `finally`, ce qu'un processus tue ne
328
+ fait jamais.
329
+ """
330
+ extracteur._decouper_en_lots(_pdf(tmp_path / "d.pdf", pages=5))
331
+
332
+ assert sorted(p.name for p in tmp_path.glob("*.pdf")) == ["d.pdf"]
tests/test_utils/test_markdown_cleanup.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nettoyage du markdown d'extraction.
3
+
4
+ Ces six tests ont suivi leur code : ils vivaient dans
5
+ `test_llama_parse_service.py` et auraient disparu avec lui. Les regles qu'ils
6
+ figent ne dependent d'aucun fournisseur — elles decrivent ce que le corpus
7
+ prc.cm contient et qu'il ne faut pas indexer — et servent autant a l'extraction
8
+ par Gemini qu'a celle qu'elles protegeaient avant.
9
+
10
+ Usage:
11
+ pytest tests/test_utils/test_markdown_cleanup.py -v
12
+ """
13
+
14
+ from app.utils.markdown_cleanup import strip_stamp_blocks
15
+
16
+
17
+ class TestNettoyageDesCachets:
18
+ """
19
+ Retrait du cachet officiel apposé sur chaque page du corpus prc.cm.
20
+
21
+ L'extracteur restitue ce cachet en texte ; sans nettoyage il pollue le FTS et
22
+ les embeddings de tous les documents.
23
+ """
24
+
25
+ def test_bloc_multiligne_retire(self):
26
+ md = (
27
+ "Article 1er. Contenu juridique réel.\n\n"
28
+ "PRESIDENCE DE LA REPUBLIQUE\n"
29
+ "SERVICE DU FICHIER LEGISLATIF ET REGLEMENTAIRE\n"
30
+ "COPIE CERTIFIEE CONFORME\n"
31
+ "CERTIFIED TRUE COPY\n\n"
32
+ "Article 2. Autre contenu."
33
+ )
34
+ out = strip_stamp_blocks(md)
35
+ assert "COPIE CERTIFIEE CONFORME" not in out
36
+ assert "Article 1er. Contenu juridique réel." in out
37
+ assert "Article 2. Autre contenu." in out
38
+
39
+ def test_cachet_sur_une_seule_ligne_retire(self):
40
+ """LlamaParse condense parfois tout le cachet sur une ligne."""
41
+ md = (
42
+ "Article 1er. Contenu.\n"
43
+ "[signature: PRESIDENCE DE LA REPUBLIQUE SECRETARIAT GENERAL "
44
+ "COPIE CERTIFIEE CONFORME CERTIFIED TRUE COPY]\n"
45
+ )
46
+ out = strip_stamp_blocks(md)
47
+ assert "CERTIFIED TRUE COPY" not in out
48
+ assert "Article 1er. Contenu." in out
49
+
50
+ def test_en_tete_legitime_conserve(self):
51
+ """
52
+ Beaucoup de décrets portent « PRESIDENCE DE LA REPUBLIQUE » comme
53
+ autorité émettrice : c'est du contenu, pas un tampon. Une seule phrase
54
+ du vocabulaire ne doit donc rien déclencher.
55
+ """
56
+ md = "PRESIDENCE DE LA REPUBLIQUE\n\nDECRET N° 2024/191 du 4 juin 2024"
57
+ out = strip_stamp_blocks(md)
58
+ assert "PRESIDENCE DE LA REPUBLIQUE" in out
59
+ assert "DECRET N° 2024/191" in out
60
+
61
+ def test_filigrane_retire(self):
62
+ md = "Article 1er.\nw\nw\nw\n.prc\n.cm\nContenu."
63
+ out = strip_stamp_blocks(md)
64
+ assert "Article 1er." in out and "Contenu." in out
65
+ assert "\nw\n" not in f"\n{out}\n"
66
+
67
+ def test_tableaux_conserves(self):
68
+ """Les <table> portent du sens (annexes budgétaires) — jamais retirés."""
69
+ md = "<table><tr><td>Recettes</td><td>66 900 000</td></tr></table>"
70
+ assert "<table>" in strip_stamp_blocks(md)
71
+
72
+ def test_balises_inline_retirees(self):
73
+ """<sup> casse la détection d'article : ARTICLE 1<sup>ER</sup>."""
74
+ assert strip_stamp_blocks("**ARTICLE 1<sup>ER</sup>**") == "**ARTICLE 1ER**"