Files
siop2/apps/ai/src/siop_ai/recherche.py
pr-daaif 837dcba1db 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>
2026-07-17 12:22:15 +01:00

58 lines
1.8 KiB
Python

"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
"""
from dataclasses import dataclass
import asyncpg
from .embeddings import Embeddeur
from .ingestion import _vecteur_sql
@dataclass
class ExtraitTrouve:
source_type: str
document_id: str | None
work_order_id: str | None
titre: str # nom de fichier ou référence d'OT
locator: str
content: str
score: float # similarité cosinus (0..1)
async def chercher(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
question: str,
limite: int = 5,
) -> list[ExtraitTrouve]:
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
lignes = await cnx.fetch(
'''
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
1 - (c.embedding <=> $1::vector) AS score,
COALESCE(d."fileName", wo.reference, '?') AS titre
FROM "RagChunk" c
LEFT JOIN "Document" d ON d.id = c."documentId"
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
ORDER BY c.embedding <=> $1::vector
LIMIT $2
''',
vecteur,
limite,
)
return [
ExtraitTrouve(
source_type=ligne["sourceType"],
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
titre=ligne["titre"],
locator=ligne["locator"],
content=ligne["content"],
score=float(ligne["score"]),
)
for ligne in lignes
]