feat(r5.1): socle apps/ai — ingestion anonymisée + recherche sémantique

ADR-004 : embeddings locaux sur CPU (fastembed ONNX,
paraphrase-multilingual-MiniLM-L12-v2, 384 dims — les textes du client
ne quittent jamais le serveur), pgvector dans le Postgres existant
(RagChunk possédé par Prisma, migration r5_ia + état de corpus sur
Document), génération opt-in (mode extractif par défaut : la recette
passe sans clé API), service siop2-ai jamais exposé — joint par l'API
NestJS seule (X-Service-Token).

apps/ai (FastAPI + uv) : pipeline PDF MinIO → texte paginé (pypdf) →
anonymisation D4 (e-mails, téléphones marocains, noms connus de la
base, insensible casse/accents — fonction pure testée) → découpage
avec chevauchement (testé) → embeddings → RagChunk localisé (« p. 42 »,
« bilan du 17/07 »). Bilans codés clôturés ingérés. Exclusion de
corpus (D3) appliquée à l'ingestion ET à la lecture.

14 pytest + ruff, embeddeur déterministe en CI (aucun téléchargement),
job CI ai (uv), deploy en dépend. Vérifié en réel avec le vrai modèle :
corpus seedé réindexé en 7 s (PDF réel → 31 extraits paginés + 3
bilans), recherche sémantique concluante, e-mails → ⟨contact⟩,
0 identité dans les chunks (contrôle SQL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 12:22:15 +01:00
parent 199fce69d0
commit 837dcba1db
22 changed files with 2564 additions and 4 deletions

View File

@@ -0,0 +1,33 @@
"""D4 (loi 09-08) : rien d'identifiant ne doit survivre à l'ingestion."""
from siop_ai.anonymisation import JETON_CONTACT, JETON_PERSONNE, anonymiser
def test_emails_et_telephones_marocains():
texte = "Appeler M. Alami au 06 12 34 56 78 ou +212 5 22 34 56 78, sinon gardien@residence.ma"
resultat = anonymiser(texte, ["M. Alami"])
assert "06 12" not in resultat
assert "+212" not in resultat
assert "gardien@residence.ma" not in resultat
assert resultat.count(JETON_CONTACT) == 3
def test_noms_connus_meme_sans_accents_ni_casse():
texte = "Intervention validée par salma idrissi puis contrôlée par AHMED BENALI."
resultat = anonymiser(texte, ["Salma Idrissi", "Ahmed Benali"])
assert "idrissi" not in resultat.lower()
assert "benali" not in resultat.lower()
assert resultat.count(JETON_PERSONNE) == 2
def test_prenom_seul_est_attrape_mais_pas_les_mots_courts():
resultat = anonymiser("Vu avec Ahmed sur site.", ["Ahmed Benali"])
assert "Ahmed" not in resultat
# « NC-31 » ou « vis » ne doivent jamais être mutilés par un nom court
resultat2 = anonymiser("Contact NC-31 réglé, vis serrées.", ["N. C."])
assert "NC-31" in resultat2
def test_le_texte_technique_reste_intact():
texte = "Serrer les coulisseaux au couple de 25 N·m ; jeu latéral 0,5 mm."
assert anonymiser(texte, ["Salma Idrissi"]) == texte

37
apps/ai/tests/test_app.py Normal file
View File

@@ -0,0 +1,37 @@
"""Le service n'est jamais public : sans le jeton de service, 401 partout
(la santé exceptée — sonde d'infra qui ne révèle rien)."""
from fastapi.testclient import TestClient
from siop_ai.app import app
def test_sante_publique_interne():
reponse = _client_sans_db().get("/healthz")
assert reponse.status_code == 200
assert reponse.json()["service"] == "siop2-ai"
def test_endpoints_internes_refuses_sans_jeton():
client = _client_sans_db()
assert client.post("/internal/reindex").status_code == 401
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
def test_question_trop_courte_rejetee_avant_tout():
reponse = _client_sans_db().post(
"/internal/search",
json={"question": "ab"},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 422
def _client_sans_db() -> TestClient:
"""TestClient HORS gestionnaire de contexte : le lifespan (pool DB,
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
from siop_ai.config import charger_reglages
app.state.reglages = charger_reglages()
return TestClient(app, raise_server_exceptions=False)

View File

@@ -0,0 +1,24 @@
from siop_ai.decoupage import CHEVAUCHEMENT, TAILLE_CIBLE, decouper
def test_texte_court_un_seul_extrait():
extraits = decouper("Serrer les vis de fixation des coulisseaux au couple de 25 N·m.")
assert len(extraits) == 1
def test_les_miettes_sont_ecartees():
assert decouper("p. 3\n\n7\n\n") == []
def test_long_texte_decoupe_avec_chevauchement():
paragraphe = "La procédure de maintenance impose un contrôle mensuel des organes. " * 40
extraits = decouper(paragraphe)
assert len(extraits) >= 2
assert all(len(e) <= TAILLE_CIBLE + CHEVAUCHEMENT for e in extraits)
def test_paragraphes_courts_regroupes():
texte = "\n\n".join(f"Étape {i} : vérifier le verrouillage de la porte palière." for i in range(6))
extraits = decouper(texte)
assert len(extraits) == 1
assert "Étape 0" in extraits[0] and "Étape 5" in extraits[0]

View File

@@ -0,0 +1,26 @@
from siop_ai.embeddings import DIMENSIONS, EmbeddeurDeterministe
def test_dimensions_et_normalisation():
[vecteur] = EmbeddeurDeterministe().encoder(["couple de serrage des guides"])
assert len(vecteur) == DIMENSIONS
assert abs(sum(v * v for v in vecteur) - 1.0) < 1e-6
def test_stable_et_discriminant():
embeddeur = EmbeddeurDeterministe()
a1 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
a2 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
b = embeddeur.encoder(["planning des congés du personnel administratif"])[0]
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731 — vecteurs normés
assert a1 == a2
assert cosinus(a1, b) < 0.35 < cosinus(a1, a1)
def test_textes_proches_plus_similaires_que_textes_eloignes():
embeddeur = EmbeddeurDeterministe()
question = embeddeur.encoder(["quel couple de serrage pour les guides ?"])[0]
notice = embeddeur.encoder(["serrer les coulisseaux de guides au couple de 25 N·m"])[0]
horsujet = embeddeur.encoder(["le syndic organise une assemblée générale annuelle"])[0]
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731
assert cosinus(question, notice) > cosinus(question, horsujet)