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,62 @@
"""Anonymisation à l'ingestion (D4, loi 09-08) : identités et coordonnées ne
partent JAMAIS dans les index vectoriels ni dans les prompts.
Fonction pure, testée : e-mails, téléphones (formats marocains et
internationaux), et les noms de personnes CONNUS de la base (utilisateurs,
gardiens, contacts tiers) fournis par l'appelant.
"""
import re
import unicodedata
JETON_PERSONNE = "⟨personne⟩"
JETON_CONTACT = "⟨contact⟩"
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
# 06 12 34 56 78 · 0612345678 · +212 6 12 34 56 78 · 05 22-34-56-78…
_TELEPHONE = re.compile(r"(?:\+?\d{1,3}[\s.-]?)?(?:0|\(0\))?\d(?:[\s.-]?\d{2}){4}")
def _sans_accents(texte: str) -> str:
return "".join(
c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn"
)
def anonymiser(texte: str, noms_connus: list[str] | None = None) -> str:
"""Remplace coordonnées et noms connus par des jetons neutres.
Les noms sont remplacés insensiblement à la casse ET aux accents
(« Idrissi » attrape « idrissi »), prénom seul compris quand il est
assez long pour ne pas mutiler le texte technique.
"""
resultat = _EMAIL.sub(JETON_CONTACT, texte)
resultat = _TELEPHONE.sub(JETON_CONTACT, resultat)
for nom in sorted(noms_connus or [], key=len, reverse=True):
nom = nom.strip()
if len(nom) < 3:
continue
morceaux = [nom] + [m for m in nom.split() if len(m) >= 4]
for morceau in morceaux:
motif = re.compile(
r"\b" + re.escape(_sans_accents(morceau)) + r"\b", re.IGNORECASE
)
# on cherche sur une copie sans accents mais on remplace l'original
copie = _sans_accents(resultat)
sortie: list[str] = []
position = 0
for correspondance in motif.finditer(copie):
sortie.append(resultat[position : correspondance.start()])
sortie.append(JETON_PERSONNE)
position = correspondance.end()
sortie.append(resultat[position:])
resultat = "".join(sortie)
# jetons collés en double (« prénom nom » remplacés séparément)
resultat = re.sub(
rf"{re.escape(JETON_PERSONNE)}(\s+{re.escape(JETON_PERSONNE)})+",
JETON_PERSONNE,
resultat,
)
return resultat