Spaces:
Runtime error
feat(extraction): remplacer LlamaParse par Gemini multimodal
Browse filesLa 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 +7 -8
- README.md +25 -11
- app/core/config.py +10 -6
- app/services/llama_parse_service.py +0 -594
- app/services/pdf_extraction_service.py +529 -0
- app/tasks/process_law.py +32 -23
- app/utils/markdown_cleanup.py +108 -0
- scripts/eval/extraction/baseline.py +9 -4
- scripts/maintenance/reextract_corpus.py +206 -0
- tests/test_services/test_llama_parse_service.py +0 -305
- tests/test_services/test_pdf_extraction.py +332 -0
- tests/test_utils/test_markdown_cleanup.py +74 -0
|
@@ -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 |
-
# ----
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
#
|
| 30 |
-
#
|
| 31 |
-
#
|
| 32 |
-
|
| 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 ----
|
|
@@ -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 |
-
|
|
| 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 |
-
| `
|
| 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 →
|
| 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é.**
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -71,13 +71,17 @@ class Settings(BaseSettings):
|
|
| 71 |
TEXT_WEIGHT: float = 0.4
|
| 72 |
SEMANTIC_WEIGHT: float = 0.6
|
| 73 |
|
| 74 |
-
#
|
| 75 |
-
|
| 76 |
-
#
|
| 77 |
-
#
|
| 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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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()
|
|
@@ -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
|
| 298 |
|
| 299 |
-
Le repli
|
| 300 |
-
pypdf renvoie la couche texte deja presente dans
|
| 301 |
-
~20% de rappel (filigrane injecte au milieu des
|
| 302 |
-
du charabia, 21% des documents sans aucun texte
|
| 303 |
-
etait alors publie avec ce contenu et marque
|
|
|
|
| 304 |
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
| 308 |
|
| 309 |
Returns:
|
| 310 |
-
(texte,
|
|
|
|
|
|
|
| 311 |
|
| 312 |
Raises:
|
| 313 |
-
|
| 314 |
"""
|
| 315 |
|
| 316 |
-
from app.services.
|
| 317 |
-
|
| 318 |
-
|
| 319 |
)
|
| 320 |
|
| 321 |
-
|
| 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
|
| 327 |
-
"
|
| 328 |
"Aucun repli degrade n'est utilise."
|
| 329 |
)
|
| 330 |
|
| 331 |
-
logger.info(f"📄 Extraction
|
| 332 |
text = _run_blocking(service.extract_text(file_path))
|
| 333 |
-
logger.info(f"✅
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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()
|
|
@@ -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
|
| 22 |
-
app/
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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())
|
|
@@ -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")) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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"]
|
|
@@ -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**"
|