Spaces:
Runtime error
Etape 14 : tests du code ajoute (54 nouveaux tests)
Browse filesLe code ecrit pendant ce chantier n'avait aucun test.
tests/test_api/test_auth_routes.py (16) — connexion JSON et formulaire, mot de
passe faux, email inconnu avec message IDENTIQUE (verifie explicitement : les
distinguer permettrait d'enumerer les comptes), last_login_at, /auth/me avec et
sans jeton, jeton malforme, matrice d'autorisation 401/403/200.
Un test parcourt les routes montees et echoue si un chemin sous /admin/ ou
/ocr/ n'a aucune dependance d'authentification : c'est lui qui rattrapera le
prochain endpoint ajoute sans garde.
tests/test_services/test_llama_parse_service.py (18) — respx intercepte httpx,
donc aucun appel reseau et aucun credit consomme. Couvre le nettoyage des
cachets (bloc multiligne, cachet condense sur une ligne, en-tete legitime
CONSERVE, filigrane, tableaux preserves, <sup> retire), le cache sha256
(verifie qu'un second appel ne repart pas sur le reseau), l'echec de job,
l'absence de retry sur 401, et les quatre formes de reponse de l'API v2.
tests/test_utils/test_chunk_refiner.py (20) — les 7 regles. Notamment :
l'en-tete de contexte est dans embed_text et jamais dans content ; PARA_n n'est
pas annonce comme un numero d'article (sinon le chatbot citerait des references
inexistantes) ; une suite de page n'est pas confondue avec des visas ; 20
personnes donnent 1 vecteur et non 20, avec appariement nom/matricule verifie ;
un tableau n'est jamais coupe ; et rien n'est jamais supprime, seulement
hierarchise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests des routes d'authentification.
|
| 3 |
+
|
| 4 |
+
Aucun test ne couvrait l'authentification : elle n'existait pas (les endpoints
|
| 5 |
+
d'administration passaient par des stubs renvoyant un dictionnaire en dur).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from httpx import AsyncClient
|
| 10 |
+
|
| 11 |
+
PASSWORD = "MotDePasseTest123"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestLogin:
|
| 15 |
+
"""Connexion."""
|
| 16 |
+
|
| 17 |
+
@pytest.mark.asyncio
|
| 18 |
+
async def test_login_json_succeeds(self, client: AsyncClient, test_admin_user):
|
| 19 |
+
r = await client.post(
|
| 20 |
+
"/api/v1/auth/login/json",
|
| 21 |
+
json={"email": test_admin_user.email, "password": PASSWORD},
|
| 22 |
+
)
|
| 23 |
+
assert r.status_code == 200
|
| 24 |
+
body = r.json()
|
| 25 |
+
assert body["access_token"]
|
| 26 |
+
assert body["token_type"] == "bearer"
|
| 27 |
+
assert body["role"] == "admin"
|
| 28 |
+
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_login_form_succeeds(self, client: AsyncClient, test_admin_user):
|
| 31 |
+
"""Le formulaire OAuth2 alimente le bouton Authorize de /docs."""
|
| 32 |
+
r = await client.post(
|
| 33 |
+
"/api/v1/auth/login",
|
| 34 |
+
data={"username": test_admin_user.email, "password": PASSWORD},
|
| 35 |
+
)
|
| 36 |
+
assert r.status_code == 200
|
| 37 |
+
assert r.json()["access_token"]
|
| 38 |
+
|
| 39 |
+
@pytest.mark.asyncio
|
| 40 |
+
async def test_wrong_password_is_rejected(self, client: AsyncClient, test_user):
|
| 41 |
+
r = await client.post(
|
| 42 |
+
"/api/v1/auth/login/json",
|
| 43 |
+
json={"email": test_user.email, "password": "mauvais"},
|
| 44 |
+
)
|
| 45 |
+
assert r.status_code == 401
|
| 46 |
+
|
| 47 |
+
@pytest.mark.asyncio
|
| 48 |
+
async def test_unknown_email_gives_same_message(
|
| 49 |
+
self, client: AsyncClient, test_user
|
| 50 |
+
):
|
| 51 |
+
"""
|
| 52 |
+
Message identique pour email inconnu et mot de passe faux.
|
| 53 |
+
|
| 54 |
+
Les distinguer permettrait d'énumérer les comptes existants.
|
| 55 |
+
"""
|
| 56 |
+
wrong_pw = await client.post(
|
| 57 |
+
"/api/v1/auth/login/json",
|
| 58 |
+
json={"email": test_user.email, "password": "mauvais"},
|
| 59 |
+
)
|
| 60 |
+
unknown = await client.post(
|
| 61 |
+
"/api/v1/auth/login/json",
|
| 62 |
+
json={"email": "personne@nulle-part.cm", "password": "mauvais"},
|
| 63 |
+
)
|
| 64 |
+
assert wrong_pw.status_code == unknown.status_code == 401
|
| 65 |
+
assert wrong_pw.json()["detail"] == unknown.json()["detail"]
|
| 66 |
+
|
| 67 |
+
@pytest.mark.asyncio
|
| 68 |
+
async def test_last_login_is_recorded(
|
| 69 |
+
self, client: AsyncClient, test_admin_user, db_session
|
| 70 |
+
):
|
| 71 |
+
assert test_admin_user.last_login_at is None
|
| 72 |
+
await client.post(
|
| 73 |
+
"/api/v1/auth/login/json",
|
| 74 |
+
json={"email": test_admin_user.email, "password": PASSWORD},
|
| 75 |
+
)
|
| 76 |
+
await db_session.refresh(test_admin_user)
|
| 77 |
+
assert test_admin_user.last_login_at is not None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TestMe:
|
| 81 |
+
"""Identité associée au jeton."""
|
| 82 |
+
|
| 83 |
+
@pytest.mark.asyncio
|
| 84 |
+
async def test_me_requires_a_token(self, client: AsyncClient):
|
| 85 |
+
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
| 86 |
+
|
| 87 |
+
@pytest.mark.asyncio
|
| 88 |
+
async def test_me_rejects_a_bogus_token(self, client: AsyncClient):
|
| 89 |
+
r = await client.get(
|
| 90 |
+
"/api/v1/auth/me", headers={"Authorization": "Bearer pas-un-jeton"}
|
| 91 |
+
)
|
| 92 |
+
assert r.status_code == 401
|
| 93 |
+
|
| 94 |
+
@pytest.mark.asyncio
|
| 95 |
+
async def test_me_returns_the_account(
|
| 96 |
+
self, client: AsyncClient, test_user, auth_headers
|
| 97 |
+
):
|
| 98 |
+
r = await client.get("/api/v1/auth/me", headers=auth_headers)
|
| 99 |
+
assert r.status_code == 200
|
| 100 |
+
assert r.json()["email"] == test_user.email
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class TestAuthorization:
|
| 104 |
+
"""Contrôle d'accès par rôle."""
|
| 105 |
+
|
| 106 |
+
# (méthode, chemin) — endpoints qui ne doivent jamais être publics
|
| 107 |
+
PROTECTED = [
|
| 108 |
+
("get", "/api/v1/admin/users"),
|
| 109 |
+
("post", "/api/v1/search/reindex"),
|
| 110 |
+
("get", "/api/v1/search/stats"),
|
| 111 |
+
("post", "/api/v1/upload/cleanup"),
|
| 112 |
+
("get", "/api/v1/batch-upload/status"),
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
@pytest.mark.asyncio
|
| 116 |
+
@pytest.mark.parametrize("method,path", PROTECTED)
|
| 117 |
+
async def test_anonymous_access_is_refused(self, client: AsyncClient, method, path):
|
| 118 |
+
r = await getattr(client, method)(path)
|
| 119 |
+
assert r.status_code == 401, f"{path} accessible sans jeton"
|
| 120 |
+
|
| 121 |
+
@pytest.mark.asyncio
|
| 122 |
+
async def test_plain_user_cannot_reach_admin(
|
| 123 |
+
self, client: AsyncClient, auth_headers
|
| 124 |
+
):
|
| 125 |
+
"""Un jeton valide mais sans privilège doit donner 403, pas 200."""
|
| 126 |
+
r = await client.get("/api/v1/admin/users", headers=auth_headers)
|
| 127 |
+
assert r.status_code == 403
|
| 128 |
+
|
| 129 |
+
@pytest.mark.asyncio
|
| 130 |
+
async def test_admin_reaches_admin(self, client: AsyncClient, admin_headers):
|
| 131 |
+
r = await client.get("/api/v1/admin/users", headers=admin_headers)
|
| 132 |
+
assert r.status_code == 200
|
| 133 |
+
|
| 134 |
+
@pytest.mark.asyncio
|
| 135 |
+
async def test_no_protected_route_lacks_a_guard(self):
|
| 136 |
+
"""
|
| 137 |
+
Filet de sécurité : parcourt les routes montées et échoue si l'une des
|
| 138 |
+
préfixes protégés n'a aucune dépendance d'authentification.
|
| 139 |
+
|
| 140 |
+
C'est ce test qui rattrapera le prochain endpoint ajouté sans garde.
|
| 141 |
+
"""
|
| 142 |
+
from fastapi.routing import APIRoute
|
| 143 |
+
|
| 144 |
+
from app.main import app
|
| 145 |
+
|
| 146 |
+
prefixes = ("/api/v1/admin/", "/api/v1/ocr/")
|
| 147 |
+
exempt = {"/api/v1/admin/health"}
|
| 148 |
+
|
| 149 |
+
unguarded = []
|
| 150 |
+
for route in _iter_routes(app):
|
| 151 |
+
if not isinstance(route, APIRoute):
|
| 152 |
+
continue
|
| 153 |
+
if route.path in exempt or not route.path.startswith(prefixes):
|
| 154 |
+
continue
|
| 155 |
+
names = {d.name for d in route.dependant.dependencies}
|
| 156 |
+
if not any("current" in (n or "") for n in names):
|
| 157 |
+
unguarded.append(f"{sorted(route.methods)} {route.path}")
|
| 158 |
+
|
| 159 |
+
assert not unguarded, f"routes sans garde : {unguarded}"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _iter_routes(app):
|
| 163 |
+
"""Parcourt les routes, y compris celles imbriquées dans les routeurs inclus."""
|
| 164 |
+
stack = list(app.routes)
|
| 165 |
+
while stack:
|
| 166 |
+
route = stack.pop()
|
| 167 |
+
yield route
|
| 168 |
+
stack.extend(getattr(route, "routes", []) or [])
|
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
_strip_stamp_blocks,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
BASE = "https://api.cloud.llamaindex.ai/api/v2/parse"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def service(tmp_path):
|
| 25 |
+
return LlamaParseService(api_key="cle-de-test", cache_dir=tmp_path / "cache")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def pdf(tmp_path):
|
| 30 |
+
p = tmp_path / "doc.pdf"
|
| 31 |
+
p.write_bytes(b"%PDF-1.4\ncontenu\n%%EOF")
|
| 32 |
+
return p
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TestNettoyageDesCachets:
|
| 36 |
+
"""
|
| 37 |
+
Retrait du cachet officiel apposé sur chaque page du corpus prc.cm.
|
| 38 |
+
|
| 39 |
+
LlamaParse restitue ce cachet en texte ; sans nettoyage il pollue le FTS et
|
| 40 |
+
les embeddings de tous les documents.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def test_bloc_multiligne_retire(self):
|
| 44 |
+
md = (
|
| 45 |
+
"Article 1er. Contenu juridique réel.\n\n"
|
| 46 |
+
"PRESIDENCE DE LA REPUBLIQUE\n"
|
| 47 |
+
"SERVICE DU FICHIER LEGISLATIF ET REGLEMENTAIRE\n"
|
| 48 |
+
"COPIE CERTIFIEE CONFORME\n"
|
| 49 |
+
"CERTIFIED TRUE COPY\n\n"
|
| 50 |
+
"Article 2. Autre contenu."
|
| 51 |
+
)
|
| 52 |
+
out = _strip_stamp_blocks(md)
|
| 53 |
+
assert "COPIE CERTIFIEE CONFORME" not in out
|
| 54 |
+
assert "Article 1er. Contenu juridique réel." in out
|
| 55 |
+
assert "Article 2. Autre contenu." in out
|
| 56 |
+
|
| 57 |
+
def test_cachet_sur_une_seule_ligne_retire(self):
|
| 58 |
+
"""LlamaParse condense parfois tout le cachet sur une ligne."""
|
| 59 |
+
md = (
|
| 60 |
+
"Article 1er. Contenu.\n"
|
| 61 |
+
"[signature: PRESIDENCE DE LA REPUBLIQUE SECRETARIAT GENERAL "
|
| 62 |
+
"COPIE CERTIFIEE CONFORME CERTIFIED TRUE COPY]\n"
|
| 63 |
+
)
|
| 64 |
+
out = _strip_stamp_blocks(md)
|
| 65 |
+
assert "CERTIFIED TRUE COPY" not in out
|
| 66 |
+
assert "Article 1er. Contenu." in out
|
| 67 |
+
|
| 68 |
+
def test_en_tete_legitime_conserve(self):
|
| 69 |
+
"""
|
| 70 |
+
Beaucoup de décrets portent « PRESIDENCE DE LA REPUBLIQUE » comme
|
| 71 |
+
autorité émettrice : c'est du contenu, pas un tampon. Une seule phrase
|
| 72 |
+
du vocabulaire ne doit donc rien déclencher.
|
| 73 |
+
"""
|
| 74 |
+
md = "PRESIDENCE DE LA REPUBLIQUE\n\nDECRET N° 2024/191 du 4 juin 2024"
|
| 75 |
+
out = _strip_stamp_blocks(md)
|
| 76 |
+
assert "PRESIDENCE DE LA REPUBLIQUE" in out
|
| 77 |
+
assert "DECRET N° 2024/191" in out
|
| 78 |
+
|
| 79 |
+
def test_filigrane_retire(self):
|
| 80 |
+
md = "Article 1er.\nw\nw\nw\n.prc\n.cm\nContenu."
|
| 81 |
+
out = _strip_stamp_blocks(md)
|
| 82 |
+
assert "Article 1er." in out and "Contenu." in out
|
| 83 |
+
assert "\nw\n" not in f"\n{out}\n"
|
| 84 |
+
|
| 85 |
+
def test_tableaux_conserves(self):
|
| 86 |
+
"""Les <table> portent du sens (annexes budgétaires) — jamais retirés."""
|
| 87 |
+
md = "<table><tr><td>Recettes</td><td>66 900 000</td></tr></table>"
|
| 88 |
+
assert "<table>" in _strip_stamp_blocks(md)
|
| 89 |
+
|
| 90 |
+
def test_balises_inline_retirees(self):
|
| 91 |
+
"""<sup> casse la détection d'article : ARTICLE 1<sup>ER</sup>."""
|
| 92 |
+
assert _strip_stamp_blocks("**ARTICLE 1<sup>ER</sup>**") == "**ARTICLE 1ER**"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class TestDisponibilite:
|
| 96 |
+
def test_indisponible_sans_cle(self, tmp_path, monkeypatch):
|
| 97 |
+
# api_key="" retombe volontairement sur settings.LLAMA_CLOUD_API_KEY :
|
| 98 |
+
# il faut donc neutraliser aussi la configuration pour tester ce cas.
|
| 99 |
+
monkeypatch.setattr(
|
| 100 |
+
"app.services.llama_parse_service.settings.LLAMA_CLOUD_API_KEY", ""
|
| 101 |
+
)
|
| 102 |
+
assert not LlamaParseService(api_key="", cache_dir=tmp_path).is_available()
|
| 103 |
+
|
| 104 |
+
def test_disponible_avec_cle(self, service):
|
| 105 |
+
assert service.is_available()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class TestExtraction:
|
| 109 |
+
@pytest.mark.asyncio
|
| 110 |
+
@respx.mock
|
| 111 |
+
async def test_extraction_et_marqueurs_de_page(self, service, pdf):
|
| 112 |
+
respx.post(f"{BASE}/upload").mock(
|
| 113 |
+
return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
|
| 114 |
+
)
|
| 115 |
+
respx.get(f"{BASE}/job-1").mock(
|
| 116 |
+
return_value=httpx.Response(
|
| 117 |
+
200,
|
| 118 |
+
json={
|
| 119 |
+
"job": {"status": "COMPLETED"},
|
| 120 |
+
"markdown": [{"markdown": "Page une"}, {"markdown": "Page deux"}],
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
)
|
| 124 |
+
service.POLL_INTERVAL = 0
|
| 125 |
+
text = await service.extract_text(pdf)
|
| 126 |
+
# Les marqueurs alimentent Article.page_number via text_chunker
|
| 127 |
+
assert "<<PAGE:1>>" in text and "<<PAGE:2>>" in text
|
| 128 |
+
assert "Page une" in text and "Page deux" in text
|
| 129 |
+
|
| 130 |
+
@pytest.mark.asyncio
|
| 131 |
+
@respx.mock
|
| 132 |
+
async def test_cache_evite_un_second_appel(self, service, pdf):
|
| 133 |
+
upload = respx.post(f"{BASE}/upload").mock(
|
| 134 |
+
return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
|
| 135 |
+
)
|
| 136 |
+
respx.get(f"{BASE}/job-1").mock(
|
| 137 |
+
return_value=httpx.Response(
|
| 138 |
+
200,
|
| 139 |
+
json={"job": {"status": "COMPLETED"}, "markdown": [{"markdown": "Texte"}]},
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
service.POLL_INTERVAL = 0
|
| 143 |
+
first = await service.extract_text(pdf)
|
| 144 |
+
second = await service.extract_text(pdf)
|
| 145 |
+
assert first == second
|
| 146 |
+
assert upload.call_count == 1, "le second appel aurait dû être servi par le cache"
|
| 147 |
+
|
| 148 |
+
@pytest.mark.asyncio
|
| 149 |
+
@respx.mock
|
| 150 |
+
async def test_job_en_echec_leve(self, service, pdf):
|
| 151 |
+
respx.post(f"{BASE}/upload").mock(
|
| 152 |
+
return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
|
| 153 |
+
)
|
| 154 |
+
respx.get(f"{BASE}/job-1").mock(
|
| 155 |
+
return_value=httpx.Response(
|
| 156 |
+
200,
|
| 157 |
+
json={"job": {"status": "FAILED", "error_message": "document illisible"}},
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
+
service.POLL_INTERVAL = 0
|
| 161 |
+
with pytest.raises(LlamaParseError, match="document illisible"):
|
| 162 |
+
await service.extract_text(pdf)
|
| 163 |
+
|
| 164 |
+
@pytest.mark.asyncio
|
| 165 |
+
@respx.mock
|
| 166 |
+
async def test_cle_refusee_ne_declenche_pas_de_retry(self, service, pdf):
|
| 167 |
+
"""Un 401 est définitif : réessayer 4 fois ne ferait que perdre du temps."""
|
| 168 |
+
route = respx.post(f"{BASE}/upload").mock(
|
| 169 |
+
return_value=httpx.Response(401, text="cle invalide")
|
| 170 |
+
)
|
| 171 |
+
service.POLL_INTERVAL = 0
|
| 172 |
+
with pytest.raises(LlamaParseError):
|
| 173 |
+
await service.extract_text(pdf)
|
| 174 |
+
assert route.call_count == 1
|
| 175 |
+
|
| 176 |
+
@pytest.mark.asyncio
|
| 177 |
+
async def test_fichier_absent(self, service, tmp_path):
|
| 178 |
+
with pytest.raises(LlamaParseError, match="introuvable"):
|
| 179 |
+
await service.extract_text(tmp_path / "absent.pdf")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class TestNormalisationDesReponses:
|
| 183 |
+
"""L'API v2 renvoie le markdown sous plusieurs formes selon les options."""
|
| 184 |
+
|
| 185 |
+
def test_liste_de_pages(self):
|
| 186 |
+
pages = LlamaParseService._pages_from(
|
| 187 |
+
{"markdown": [{"markdown": "a"}, {"markdown": "b"}]}
|
| 188 |
+
)
|
| 189 |
+
assert pages == ["a", "b"]
|
| 190 |
+
|
| 191 |
+
def test_chaine_unique(self):
|
| 192 |
+
assert LlamaParseService._pages_from({"markdown": "seule"}) == ["seule"]
|
| 193 |
+
|
| 194 |
+
def test_champ_pages(self):
|
| 195 |
+
assert LlamaParseService._pages_from({"pages": [{"markdown": "x"}]}) == ["x"]
|
| 196 |
+
|
| 197 |
+
def test_markdown_full(self):
|
| 198 |
+
assert LlamaParseService._pages_from({"markdown_full": "tout"}) == ["tout"]
|
| 199 |
+
|
| 200 |
+
def test_reponse_vide(self):
|
| 201 |
+
assert LlamaParseService._pages_from({}) == []
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests du raffineur de chunks (règles R2 à R7).
|
| 3 |
+
|
| 4 |
+
Le module n'avait aucun test. Ces règles déterminent directement ce que le
|
| 5 |
+
modèle reçoit en contexte : une erreur ici se traduit par des réponses sans
|
| 6 |
+
fondement, sans qu'aucune exception ne soit levée.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.utils.chunk_refiner import (
|
| 12 |
+
DocumentContext,
|
| 13 |
+
normalize_for_chunking,
|
| 14 |
+
refine,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def contexte():
|
| 20 |
+
return DocumentContext(
|
| 21 |
+
reference="Décret n° 2024/191",
|
| 22 |
+
title="portant ratification de la Convention de Crédit-Acheteur",
|
| 23 |
+
doc_type="decret",
|
| 24 |
+
date="4 juin 2024",
|
| 25 |
+
category="Finances publiques",
|
| 26 |
+
language="fr",
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def chunk(number, content, **kw):
|
| 31 |
+
"""Construit un chunk au format produit par text_chunker."""
|
| 32 |
+
base = {
|
| 33 |
+
"number": number,
|
| 34 |
+
"title": None,
|
| 35 |
+
"content": content,
|
| 36 |
+
"position": 0,
|
| 37 |
+
"parent_id": None,
|
| 38 |
+
"section": None,
|
| 39 |
+
"word_count": len(content.split()),
|
| 40 |
+
"char_count": len(content),
|
| 41 |
+
"page_number": 1,
|
| 42 |
+
}
|
| 43 |
+
base.update(kw)
|
| 44 |
+
return base
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TestNormalisation:
|
| 48 |
+
"""Prépare le markdown OCR pour text_chunker."""
|
| 49 |
+
|
| 50 |
+
def test_gras_markdown_retire(self):
|
| 51 |
+
"""**ARTICLE 1ER** empêche la détection : le motif attend « Article »
|
| 52 |
+
en début de ligne, pas « **Article »."""
|
| 53 |
+
assert "ARTICLE 1ER" in normalize_for_chunking("**ARTICLE 1ER**: Contenu")
|
| 54 |
+
assert "**" not in normalize_for_chunking("**ARTICLE 1ER**: Contenu")
|
| 55 |
+
|
| 56 |
+
def test_titre_markdown_retire(self):
|
| 57 |
+
assert normalize_for_chunking("# Titre").strip() == "Titre"
|
| 58 |
+
|
| 59 |
+
def test_lettres_espacees_recollees(self):
|
| 60 |
+
"""L'OCR restitue l'interlettrage des titres : « A R R Ê T E »."""
|
| 61 |
+
assert "ARRÊTE" in normalize_for_chunking("A R R Ê T E :")
|
| 62 |
+
|
| 63 |
+
def test_marqueurs_de_page_preserves(self):
|
| 64 |
+
assert "<<PAGE:3>>" in normalize_for_chunking("<<PAGE:3>>\nTexte")
|
| 65 |
+
|
| 66 |
+
def test_tableaux_preserves(self):
|
| 67 |
+
out = normalize_for_chunking("<table><tr><td>a</td></tr></table>")
|
| 68 |
+
assert "<table>" in out
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TestR2Contextualisation:
|
| 72 |
+
def test_embed_text_porte_le_contexte(self, contexte):
|
| 73 |
+
r = refine([chunk("3", "La dépense résultant des présentes dispositions." * 5)], contexte)
|
| 74 |
+
embeddable = r.embeddable
|
| 75 |
+
assert embeddable, "le chunk aurait dû être vectorisable"
|
| 76 |
+
texte = embeddable[0]["embed_text"]
|
| 77 |
+
# Sans en-tête, « La dépense résultant des présentes dispositions » est
|
| 78 |
+
# un chunk orphelin : ni le lecteur ni l'embedding ne savent de quoi il parle.
|
| 79 |
+
assert "Décret n° 2024/191" in texte
|
| 80 |
+
assert "Finances publiques" in texte
|
| 81 |
+
assert "Article 3" in texte
|
| 82 |
+
|
| 83 |
+
def test_pas_de_libelle_article_pour_un_repli_paragraphe(self, contexte):
|
| 84 |
+
"""PARA_n n'est pas un vrai numéro : l'annoncer ferait citer au chatbot
|
| 85 |
+
des références inexistantes."""
|
| 86 |
+
r = refine([chunk("PARA_2", "Contenu quelconque suffisamment long." * 4)], contexte)
|
| 87 |
+
assert "Article PARA_2" not in r.embeddable[0]["embed_text"]
|
| 88 |
+
|
| 89 |
+
def test_contenu_non_modifie(self, contexte):
|
| 90 |
+
"""Le contexte va dans embed_text, jamais dans content (affichage/citation)."""
|
| 91 |
+
contenu = "Article utile avec un contenu suffisamment long pour être conservé."
|
| 92 |
+
r = refine([chunk("1", contenu)], contexte)
|
| 93 |
+
assert r.chunks[0]["content"] == contenu
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class TestR3Visas:
|
| 97 |
+
def test_visas_hors_index_et_cites(self, contexte):
|
| 98 |
+
visas = (
|
| 99 |
+
"Vu la Constitution ;\n"
|
| 100 |
+
"Vu la loi n° 2007-006 du 26 décembre 2007 portant régime financier ;\n"
|
| 101 |
+
"Vu le décret n° 2011/412 du 09 décembre 2011 portant réorganisation ;"
|
| 102 |
+
)
|
| 103 |
+
r = refine([chunk("LEGAL_BASIS", visas)], contexte)
|
| 104 |
+
assert r.legal_basis is not None
|
| 105 |
+
assert not r.embeddable, "les visas ne doivent pas être vectorisés"
|
| 106 |
+
# Présents dans chaque document, ils domineraient le FTS et écraseraient
|
| 107 |
+
# la similarité cosinus.
|
| 108 |
+
assert any("2007-006" in c for c in r.citations)
|
| 109 |
+
assert any("2011/412" in c for c in r.citations)
|
| 110 |
+
|
| 111 |
+
def test_suite_de_page_reste_indexee(self, contexte):
|
| 112 |
+
"""
|
| 113 |
+
text_chunker étiquette LEGAL_BASIS tout le texte précédant le premier
|
| 114 |
+
article. Sur une page qui ne commence pas par un article, c'est du
|
| 115 |
+
contenu juridique réel : l'exclure le ferait disparaître de la recherche.
|
| 116 |
+
"""
|
| 117 |
+
suite = "la délivrance des titres de passeports ; l'obtention d'une carte grise." * 3
|
| 118 |
+
r = refine([chunk("LEGAL_BASIS", suite)], contexte)
|
| 119 |
+
assert r.legal_basis is None
|
| 120 |
+
assert r.chunks[0]["kind"] == "continuation"
|
| 121 |
+
assert r.chunks[0]["embed"] is True
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class TestR4ListesNominatives:
|
| 125 |
+
TABLE = "<table><tbody>" + "".join(
|
| 126 |
+
f"<tr><td>{i}.</td><td>NOM PRENOM{i:02d}</td><td>765 6{i:02d}-Y</td></tr>"
|
| 127 |
+
for i in range(1, 21)
|
| 128 |
+
) + "</tbody></table>"
|
| 129 |
+
|
| 130 |
+
def test_liste_effondree_en_un_chunk(self, contexte):
|
| 131 |
+
contenu = (
|
| 132 |
+
"Les anciens Gardiens de la Paix dont les noms suivent sont nommés "
|
| 133 |
+
"Élèves-Inspecteurs de Police, indice 230.\n" + self.TABLE
|
| 134 |
+
)
|
| 135 |
+
r = refine([chunk("1", contenu)], contexte)
|
| 136 |
+
assert len(r.roster) == 20
|
| 137 |
+
assert len(r.embeddable) == 1, "20 personnes ne doivent pas donner 20 vecteurs"
|
| 138 |
+
# L'enveloppe juridique est conservée, la liste ne l'est pas
|
| 139 |
+
assert "Élèves-Inspecteurs" in r.chunks[0]["content"]
|
| 140 |
+
assert "NOM PRENOM05" not in r.chunks[0]["content"]
|
| 141 |
+
assert "20 personnes concernées" in r.chunks[0]["content"]
|
| 142 |
+
|
| 143 |
+
def test_appariement_nom_matricule(self, contexte):
|
| 144 |
+
contenu = "Sont nommés :\n" + self.TABLE
|
| 145 |
+
r = refine([chunk("1", contenu)], contexte)
|
| 146 |
+
entree = r.roster[4]
|
| 147 |
+
assert entree.name == "NOM PRENOM05"
|
| 148 |
+
assert entree.identifier == "765 605-Y"
|
| 149 |
+
|
| 150 |
+
def test_entete_de_tableau_ignoree(self, contexte):
|
| 151 |
+
table = (
|
| 152 |
+
"<table><thead><tr><th>N°</th><th>Nom</th><th>Indice</th></tr></thead>"
|
| 153 |
+
"<tbody>" + "".join(
|
| 154 |
+
f"<tr><td>{i}.</td><td>NOM PRENOM{i:02d}</td><td>765 6{i:02d}-Y</td></tr>"
|
| 155 |
+
for i in range(1, 8)
|
| 156 |
+
) + "</tbody></table>"
|
| 157 |
+
)
|
| 158 |
+
r = refine([chunk("1", "Sont nommés :\n" + table)], contexte)
|
| 159 |
+
assert all(e.name != "Nom" for e in r.roster), "« Nom » compté comme personne"
|
| 160 |
+
assert len(r.roster) == 7
|
| 161 |
+
|
| 162 |
+
def test_article_normatif_non_traite_comme_liste(self, contexte):
|
| 163 |
+
contenu = (
|
| 164 |
+
"L'administration fiscale met en œuvre l'assistance internationale "
|
| 165 |
+
"en matière de recouvrement des créances fiscales, qu'elle soit "
|
| 166 |
+
"sollicitée par une autorité étrangère ou qu'elle en fasse la demande."
|
| 167 |
+
)
|
| 168 |
+
r = refine([chunk("L 94 septies", contenu)], contexte)
|
| 169 |
+
assert not r.roster
|
| 170 |
+
assert r.chunks[0]["kind"] == "article"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class TestR5R6TaillesEtTableaux:
|
| 174 |
+
def test_tableau_jamais_coupe(self, contexte):
|
| 175 |
+
table = "<table>" + "".join(
|
| 176 |
+
f"<tr><td>ligne {i}</td><td>{i * 1000}</td></tr>" for i in range(200)
|
| 177 |
+
) + "</table>"
|
| 178 |
+
r = refine([chunk("86", "Les charges sont évaluées ainsi :\n" + table)], contexte)
|
| 179 |
+
# Une ligne isolée ne répond à aucune question : le tableau reste entier
|
| 180 |
+
assert len(r.chunks) == 1
|
| 181 |
+
assert r.chunks[0]["oversized"] is True
|
| 182 |
+
|
| 183 |
+
def test_article_long_coupe_aux_alineas(self, contexte):
|
| 184 |
+
contenu = "Dispositions générales.\n" + "\n".join(
|
| 185 |
+
f"({i}) " + "Texte de l'alinéa suffisamment long pour compter. " * 12
|
| 186 |
+
for i in range(1, 6)
|
| 187 |
+
)
|
| 188 |
+
r = refine([chunk("94", contenu)], contexte, target_max_chars=800)
|
| 189 |
+
assert len(r.chunks) > 1
|
| 190 |
+
assert all(c["parent_id"] == "94" for c in r.chunks[1:])
|
| 191 |
+
|
| 192 |
+
def test_article_d_execution_hors_index(self, contexte):
|
| 193 |
+
boilerplate = (
|
| 194 |
+
"Le présent décret sera enregistré, publié selon la procédure "
|
| 195 |
+
"d'urgence, puis inséré au Journal Officiel en français et en anglais."
|
| 196 |
+
)
|
| 197 |
+
r = refine([chunk("5", boilerplate)], contexte)
|
| 198 |
+
# Présent dans presque chaque texte : indexé, il produirait des milliers
|
| 199 |
+
# de quasi-doublons. Conservé en base, retiré du vectoriel.
|
| 200 |
+
assert r.chunks[0]["kind"] == "boilerplate"
|
| 201 |
+
assert r.chunks[0]["embed"] is False
|
| 202 |
+
assert r.chunks[0]["content"] == boilerplate
|
| 203 |
+
|
| 204 |
+
def test_fragment_trop_court_hors_index(self, contexte):
|
| 205 |
+
r = refine([chunk("PARA_9", "45")], contexte)
|
| 206 |
+
assert r.chunks[0]["embed"] is False
|
| 207 |
+
assert r.chunks[0]["kind"] == "fragment"
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class TestR7Deduplication:
|
| 211 |
+
def test_chunks_identiques_fusionnes(self, contexte):
|
| 212 |
+
contenu = "Le présent texte entre en application immédiate sur tout le territoire."
|
| 213 |
+
r = refine([chunk("1", contenu), chunk("2", contenu)], contexte)
|
| 214 |
+
assert r.stats["duplicates_removed"] == 1
|
| 215 |
+
assert len(r.chunks) == 1
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class TestRienNEstPerdu:
|
| 219 |
+
def test_aucun_chunk_supprime(self, contexte):
|
| 220 |
+
"""
|
| 221 |
+
Principe directeur du module : les chunks écartés du vectoriel gardent
|
| 222 |
+
embed=False mais restent en base et cherchables en FTS.
|
| 223 |
+
"""
|
| 224 |
+
chunks = [
|
| 225 |
+
chunk("LEGAL_BASIS", "Vu la Constitution ;"),
|
| 226 |
+
chunk("1", "Contenu normatif suffisamment long pour être conservé." * 3),
|
| 227 |
+
chunk("5", "Le présent décret sera enregistré et publié au Journal Officiel."),
|
| 228 |
+
chunk("PARA_9", "12"),
|
| 229 |
+
]
|
| 230 |
+
r = refine(chunks, contexte)
|
| 231 |
+
assert len(r.chunks) == len(chunks)
|
| 232 |
+
assert r.stats["embeddable"] < len(chunks), "tout ne doit pas être vectorisé"
|