Spaces:
Sleeping
Sleeping
Déploiement PolyglotRAG via notebook Colab
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +16 -0
- .env.example +55 -0
- .gitignore +14 -0
- Dockerfile +37 -0
- LICENSE +21 -0
- README.md +286 -5
- app.py +66 -0
- config.py +153 -0
- core/__init__.py +2 -0
- core/assets/architecture.svg +180 -0
- core/generation/__init__.py +0 -0
- core/generation/answer_generator.py +77 -0
- core/generation/prompts.py +63 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/chunker.py +77 -0
- core/ingestion/indexer.py +198 -0
- core/ingestion/language_detector.py +58 -0
- core/ingestion/loaders.py +119 -0
- core/metrics/__init__.py +0 -0
- core/metrics/logger.py +172 -0
- core/optimization/__init__.py +0 -0
- core/optimization/cache.py +70 -0
- core/pipeline.py +106 -0
- core/retrieval/__init__.py +0 -0
- core/retrieval/embeddings.py +94 -0
- core/retrieval/hybrid_search.py +68 -0
- core/retrieval/vector_store.py +184 -0
- core/ui/__init__.py +0 -0
- core/ui/admin_tab.py +136 -0
- core/ui/architecture_tab.py +42 -0
- core/ui/chat_tab.py +121 -0
- core/ui/theme.py +39 -0
- docker-compose.yml +15 -0
- knowledge_base/ar/سياسة_الاسترجاع_ar.md +30 -0
- knowledge_base/en/employee_handbook_en.md +33 -0
- knowledge_base/es/politica_devoluciones_es.md +33 -0
- knowledge_base/fr/politique_remboursement_fr.md +35 -0
- knowledge_base/pt/politica_de_reembolso_pt.md +32 -0
- knowledge_base/ru/faq_ru.md +33 -0
- pytest.ini +4 -0
- requirements.txt +13 -0
- scripts/ingest.py +74 -0
- tests/__init__.py +0 -0
- tests/conftest.py +56 -0
- tests/test_chunker.py +34 -0
- tests/test_indexer.py +29 -0
- tests/test_language_detector.py +33 -0
- tests/test_metrics_logger.py +39 -0
- tests/test_pipeline.py +96 -0
- tests/test_prompts.py +41 -0
.dockerignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git/
|
| 2 |
+
.github/
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
__pycache__/
|
| 6 |
+
**/__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
.pytest_cache/
|
| 10 |
+
.mypy_cache/
|
| 11 |
+
.env
|
| 12 |
+
data/
|
| 13 |
+
*.sqlite3
|
| 14 |
+
*.ipynb_checkpoints/
|
| 15 |
+
PolyglotRAG_Deploy_HuggingFace.ipynb
|
| 16 |
+
README_dev_notes.md
|
.env.example
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# === PolyglotRAG — variables d'environnement ===
|
| 2 |
+
# Copiez ce fichier en ".env" en local, ou déclarez ces clés en tant que
|
| 3 |
+
# "Secrets"/"Variables" du Space Hugging Face (jamais en clair dans le code).
|
| 4 |
+
|
| 5 |
+
# Un seul jeton nécessaire : https://huggingface.co/settings/tokens
|
| 6 |
+
# Scopes requis : "Make calls to Inference Providers" (+ "Write" si vous
|
| 7 |
+
# poussez/mettez à jour le dataset privé depuis le Space lui-même).
|
| 8 |
+
HF_TOKEN=hf_xxx_votre_jeton_ici
|
| 9 |
+
|
| 10 |
+
# Dataset privé Hugging Face contenant l'index (chunks.parquet + manifest.json)
|
| 11 |
+
# Exemple : "mon-compte/polyglot-rag-index"
|
| 12 |
+
HF_DATASET_REPO=
|
| 13 |
+
|
| 14 |
+
# Modèle génératif — DeepSeek-V4.1-Flash servi via le routeur Inference
|
| 15 |
+
# Providers. ":fastest" laisse HF choisir automatiquement le provider actif
|
| 16 |
+
# (Novita à ce jour) sans changer le code applicatif.
|
| 17 |
+
LLM_MODEL=deepseek-ai/DeepSeek-V4.1-Flash:fastest
|
| 18 |
+
LLM_TEMPERATURE=0.2
|
| 19 |
+
LLM_MAX_TOKENS=1024
|
| 20 |
+
|
| 21 |
+
# Modèle d'embeddings multilingue partagé
|
| 22 |
+
EMBEDDING_MODEL=BAAI/bge-m3
|
| 23 |
+
EMBEDDING_DIM=1024
|
| 24 |
+
USE_QUERY_PASSAGE_PREFIX=false
|
| 25 |
+
|
| 26 |
+
# Recherche hybride
|
| 27 |
+
TOP_K_RETRIEVE=15
|
| 28 |
+
# Nombre de passages réellement envoyés au LLM : volontairement limité à 2
|
| 29 |
+
# (latence/coût réduits, contexte plus dense). Augmentez si nécessaire.
|
| 30 |
+
TOP_K_FINAL=2
|
| 31 |
+
SAME_LANGUAGE_BONUS=0.04
|
| 32 |
+
MIN_CONFIDENCE_SCORE=0.28
|
| 33 |
+
|
| 34 |
+
# Index vectoriel : recherche approximative (ANN) par défaut.
|
| 35 |
+
# "hnsw" = FAISS IndexHNSWFlat (rapide, pas d'entraînement requis)
|
| 36 |
+
# "flat" = recherche exacte (IndexFlatIP), utile pour comparer la qualité
|
| 37 |
+
ANN_INDEX_TYPE=hnsw
|
| 38 |
+
ANN_HNSW_M=32
|
| 39 |
+
ANN_EF_SEARCH=64
|
| 40 |
+
|
| 41 |
+
# Cache (réponses complètes + embeddings de requêtes)
|
| 42 |
+
ENABLE_CACHE=true
|
| 43 |
+
CACHE_TTL_SECONDS=600
|
| 44 |
+
CACHE_MAX_SIZE=512
|
| 45 |
+
|
| 46 |
+
# Chunking
|
| 47 |
+
CHUNK_SIZE_TOKENS=350
|
| 48 |
+
CHUNK_OVERLAP_TOKENS=60
|
| 49 |
+
|
| 50 |
+
# Mot de passe de l'onglet Admin (laisser vide = accès libre, déconseillé en prod)
|
| 51 |
+
ADMIN_PASSWORD=
|
| 52 |
+
|
| 53 |
+
# Stockage local (cache / dev)
|
| 54 |
+
LOCAL_INDEX_DIR=data/index
|
| 55 |
+
METRICS_DB_PATH=data/metrics.sqlite3
|
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
**/__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
.env
|
| 10 |
+
data/*
|
| 11 |
+
!data/.gitkeep
|
| 12 |
+
*.sqlite3
|
| 13 |
+
.ipynb_checkpoints/
|
| 14 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PolyglotRAG — image Docker de production.
|
| 2 |
+
# Compatible : exécution locale (docker run / docker compose) ET
|
| 3 |
+
# Hugging Face Spaces en SDK "docker" (README.md: sdk: docker, app_port: 7860).
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 7 |
+
PYTHONUNBUFFERED=1 \
|
| 8 |
+
PIP_NO_CACHE_DIR=1 \
|
| 9 |
+
PORT=7860
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Dépendances système minimales : libgomp1 pour faiss-cpu, build-essential
|
| 14 |
+
# pour compiler les rares roues non pré-buildées (arm64 notamment).
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
build-essential \
|
| 17 |
+
libgomp1 \
|
| 18 |
+
curl \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
COPY requirements.txt .
|
| 22 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
COPY . .
|
| 25 |
+
|
| 26 |
+
# Hugging Face Spaces (SDK docker) exige un utilisateur non-root (UID 1000).
|
| 27 |
+
RUN useradd -m -u 1000 appuser \
|
| 28 |
+
&& mkdir -p /app/data \
|
| 29 |
+
&& chown -R appuser:appuser /app
|
| 30 |
+
USER appuser
|
| 31 |
+
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 35 |
+
CMD curl -fsS "http://127.0.0.1:${PORT}/" || exit 1
|
| 36 |
+
|
| 37 |
+
CMD ["python", "app.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 PolyglotRAG
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,291 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PolyglotRAG
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# 🌍 PolyglotRAG
|
| 13 |
+
|
| 14 |
+
Assistant documentaire multilingue et **cross-lingue** : posez une question en
|
| 15 |
+
anglais, français, russe, espagnol, arabe ou portugais, et obtenez une réponse
|
| 16 |
+
sourcée — même si le document pertinent est rédigé dans une **autre** langue
|
| 17 |
+
que la question.
|
| 18 |
+
|
| 19 |
+
> Exemple : une question posée en **russe** retrouve un règlement rédigé en
|
| 20 |
+
> **français** et un rapport en **anglais**, puis PolyglotRAG répond en russe
|
| 21 |
+
> en citant les documents originaux — sans jamais traduire le corpus au
|
| 22 |
+
> préalable.
|
| 23 |
+
|
| 24 |
+
Propulsé par **DeepSeek-V4.1-Flash** (génération) et **BAAI/bge-m3**
|
| 25 |
+
(embeddings multilingues partagés), les deux via le routeur *Inference
|
| 26 |
+
Providers* de Hugging Face — **un seul jeton d'API** suffit pour tout le
|
| 27 |
+
projet.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Sommaire
|
| 32 |
+
|
| 33 |
+
- [Fonctionnalités](#fonctionnalités)
|
| 34 |
+
- [Architecture](#architecture)
|
| 35 |
+
- [Optimisations de performance](#optimisations-de-performance)
|
| 36 |
+
- [Démarrage rapide (local, sans Docker)](#démarrage-rapide-local-sans-docker)
|
| 37 |
+
- [Docker](#docker)
|
| 38 |
+
- [Déploiement sur Hugging Face Spaces](#déploiement-sur-hugging-face-spaces)
|
| 39 |
+
- [Structure du dépôt](#structure-du-dépôt)
|
| 40 |
+
- [Configuration](#configuration)
|
| 41 |
+
- [Ingestion de vos propres documents](#ingestion-de-vos-propres-documents)
|
| 42 |
+
- [Tests](#tests)
|
| 43 |
+
- [Choix d'architecture assumés](#choix-darchitecture-assumés)
|
| 44 |
+
- [Évolutions possibles](#évolutions-possibles)
|
| 45 |
+
|
| 46 |
+
## Fonctionnalités
|
| 47 |
+
|
| 48 |
+
- 🔎 Détection automatique de la langue de la question (hors-ligne, `lingua`).
|
| 49 |
+
- 📥 Ingestion PDF, DOCX, Markdown, HTML, CSV, avec langue/fichier/page/titre
|
| 50 |
+
conservés par chunk.
|
| 51 |
+
- 🌐 Recherche **cross-lingue** dans un espace sémantique multilingue partagé
|
| 52 |
+
(BGE-M3), avec 3 modes : global, filtré par langue, priorité locale.
|
| 53 |
+
- 🧠 Génération strictement source-grounded (mode strict / abstention si le
|
| 54 |
+
score de confiance est trop faible).
|
| 55 |
+
- 🈯 Interface **RTL** correcte pour l'arabe.
|
| 56 |
+
- 📚 Citations systématiques : fichier, langue, page, score.
|
| 57 |
+
- 📊 Page **Admin** : métriques du RAG (volume, latence, langues, taux
|
| 58 |
+
d'abstention, historique des requêtes).
|
| 59 |
+
- 🏗️ Page **Architecture** : schéma complet du pipeline.
|
| 60 |
+
- 🔐 Stockage vectoriel dans un **dataset privé Hugging Face** (aucun service
|
| 61 |
+
tiers à héberger).
|
| 62 |
+
- ⚡ **Cache** réponses + embeddings de requêtes, **recherche approximative
|
| 63 |
+
(ANN / FAISS-HNSW)** et **top-k final limité à 2** — voir
|
| 64 |
+
[Optimisations de performance](#optimisations-de-performance).
|
| 65 |
+
- 🐳 **Dockerfile** fourni (image de production, utilisateur non-root,
|
| 66 |
+
healthcheck) — exécution locale ou Space Hugging Face en SDK Docker.
|
| 67 |
+
|
| 68 |
+
## Architecture
|
| 69 |
+
|
| 70 |
+
Voir l'onglet **Architecture** de l'application pour le schéma interactif.
|
| 71 |
+
Résumé :
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
Documents multilingues (knowledge_base/<lang>/*)
|
| 75 |
+
│
|
| 76 |
+
▼
|
| 77 |
+
Extraction PDF / DOCX / MD / HTML / CSV
|
| 78 |
+
│
|
| 79 |
+
▼
|
| 80 |
+
Détection langue + métadonnées (fichier, page, titre, sens RTL/LTR)
|
| 81 |
+
│
|
| 82 |
+
▼
|
| 83 |
+
Chunking adapté à la langue (découpage par phrases + recouvrement)
|
| 84 |
+
│
|
| 85 |
+
▼
|
| 86 |
+
Embeddings multilingues partagés — BAAI/bge-m3 (HF Inference Providers)
|
| 87 |
+
│
|
| 88 |
+
▼
|
| 89 |
+
Dataset privé Hugging Face (Parquet) ──► chargé en FAISS au démarrage
|
| 90 |
+
│
|
| 91 |
+
▼
|
| 92 |
+
Question utilisateur ──► détection de langue ──► recherche ANN cross-lingue (top-15, FAISS-HNSW)
|
| 93 |
+
│
|
| 94 |
+
▼
|
| 95 |
+
Bonus langue + reranking (top-2 final) ──► mode strict (seuil de confiance)
|
| 96 |
+
│
|
| 97 |
+
▼
|
| 98 |
+
DeepSeek-V4.1-Flash (routeur HF) ──► réponse + citations + score
|
| 99 |
+
│
|
| 100 |
+
▼
|
| 101 |
+
Cache réponse (TTL) ──► une question identique reposée pendant la fenêtre
|
| 102 |
+
de cache ne refait ni la recherche, ni l'appel LLM
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
## Optimisations de performance
|
| 106 |
+
|
| 107 |
+
Trois optimisations sont actives par défaut (`config.py` / `.env.example`) :
|
| 108 |
+
|
| 109 |
+
| Optimisation | Où | Détail |
|
| 110 |
+
|---|---|---|
|
| 111 |
+
| **Cache** | `core/optimization/cache.py`, `core/pipeline.py` | Cache à expiration (TTL, 10 min par défaut) sur (1) les embeddings de questions déjà posées et (2) les réponses complètes pour une question strictement identique (même langue de réponse, même filtre). Désactivable via `ENABLE_CACHE=false`. |
|
| 112 |
+
| **Recherche approximative (ANN)** | `core/retrieval/vector_store.py` | Index `FAISS IndexHNSWFlat` par défaut au lieu d'une recherche exacte (`IndexFlatIP`) : bien plus rapide dès que le corpus grossit, sans étape d'entraînement. Réglable via `ANN_INDEX_TYPE` (`hnsw`/`flat`), `ANN_HNSW_M`, `ANN_EF_SEARCH`. |
|
| 113 |
+
| **Top-k final réduit** | `config.py` (`TOP_K_FINAL=2`) | Seuls les **2 meilleurs passages** (après bonus de langue et reranking) sont envoyés au LLM — moins de tokens de contexte, réponse plus rapide et moins coûteuse. `TOP_K_RETRIEVE=15` reste le pool de candidats initial avant ce filtrage final. |
|
| 114 |
+
|
| 115 |
+
L'onglet **Admin** affiche en direct l'état du cache (taille/capacité) et le
|
| 116 |
+
type d'index vectoriel actif, pour vérifier que ces optimisations tournent
|
| 117 |
+
bien en production.
|
| 118 |
+
|
| 119 |
+
## Démarrage rapide (local, sans Docker)
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
git clone <votre-fork-ou-dossier> polyglot-rag
|
| 123 |
+
cd polyglot-rag
|
| 124 |
+
python -m venv .venv && source .venv/bin/activate # Windows : .venv\Scripts\activate
|
| 125 |
+
pip install -r requirements.txt
|
| 126 |
+
cp .env.example .env
|
| 127 |
+
# Éditez .env : renseignez HF_TOKEN (https://huggingface.co/settings/tokens)
|
| 128 |
+
|
| 129 |
+
# 1. Ingestion des documents d'exemple (ou des vôtres) -> index local
|
| 130 |
+
python scripts/ingest.py --no-push
|
| 131 |
+
|
| 132 |
+
# 2. Lancement de l'application
|
| 133 |
+
python app.py
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
L'application s'ouvre sur `http://localhost:7860` avec les 3 onglets
|
| 137 |
+
Chat / Architecture / Admin.
|
| 138 |
+
|
| 139 |
+
## Docker
|
| 140 |
+
|
| 141 |
+
Image de production fournie (utilisateur non-root UID 1000, healthcheck,
|
| 142 |
+
compatible Hugging Face Spaces en SDK Docker).
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
cp .env.example .env # renseignez HF_TOKEN au minimum
|
| 146 |
+
|
| 147 |
+
# Construction + lancement
|
| 148 |
+
docker compose up --build
|
| 149 |
+
# ou, sans docker compose :
|
| 150 |
+
docker build -t polyglot-rag .
|
| 151 |
+
docker run --rm -p 7860:7860 --env-file .env -v "$(pwd)/data:/app/data" polyglot-rag
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
Pensez à ingérer vos documents avant (ou après) le premier démarrage :
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
docker compose run --rm polyglot-rag python scripts/ingest.py --no-push
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
L'application est alors disponible sur `http://localhost:7860`.
|
| 161 |
+
|
| 162 |
+
## Déploiement sur Hugging Face Spaces
|
| 163 |
+
|
| 164 |
+
Le Space est un **Space Docker** : `README.md` déclare `sdk: docker` et
|
| 165 |
+
`app_port: 7860`, et Hugging Face construit directement le `Dockerfile`
|
| 166 |
+
fourni à la racine — aucune configuration supplémentaire n'est nécessaire
|
| 167 |
+
côté Space.
|
| 168 |
+
|
| 169 |
+
**Option recommandée : notebook Colab fourni**
|
| 170 |
+
(`PolyglotRAG_Deploy_HuggingFace.ipynb`). Il automatise tout :
|
| 171 |
+
|
| 172 |
+
1. Installation des dépendances.
|
| 173 |
+
2. Saisie sécurisée de votre `HF_TOKEN` (jamais affiché en clair).
|
| 174 |
+
3. Création du dataset privé + ingestion des documents.
|
| 175 |
+
4. Publication du dataset privé sur le Hub.
|
| 176 |
+
5. Création du Space en **SDK Docker** + injection du token en secret.
|
| 177 |
+
6. Publication du code de l'application (dont le `Dockerfile`).
|
| 178 |
+
7. Affichage de l'URL finale du Space (build Docker déclenché automatiquement
|
| 179 |
+
par Hugging Face).
|
| 180 |
+
|
| 181 |
+
**Option manuelle**, en ligne de commande :
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
python scripts/ingest.py # pousse l'index vers HF_DATASET_REPO
|
| 185 |
+
hf repos create <votre-compte>/polyglot-rag --repo-type space --space_sdk docker
|
| 186 |
+
huggingface-cli upload <votre-compte>/polyglot-rag . --repo-type space
|
| 187 |
+
# Puis, dans les Settings du Space : ajoutez les secrets HF_TOKEN et HF_DATASET_REPO
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
## Structure du dépôt
|
| 191 |
+
|
| 192 |
+
```
|
| 193 |
+
polyglot-rag/
|
| 194 |
+
├── app.py # Point d'entrée Gradio (Chat / Architecture / Admin)
|
| 195 |
+
├── config.py # Configuration centralisée (variables d'env)
|
| 196 |
+
├── Dockerfile # Image de production (Space Docker + usage local)
|
| 197 |
+
├── docker-compose.yml # Confort de développement local
|
| 198 |
+
├── .dockerignore
|
| 199 |
+
├── requirements.txt
|
| 200 |
+
├── .env.example
|
| 201 |
+
├── knowledge_base/ # Documents d'exemple (6 langues) — à remplacer par les vôtres
|
| 202 |
+
│ ├── en/ fr/ ru/ es/ ar/ pt/
|
| 203 |
+
├── core/
|
| 204 |
+
│ ├── ingestion/ # loaders, détection de langue, chunking, indexeur
|
| 205 |
+
│ ├── retrieval/ # embeddings, FAISS (ANN), recherche cross-lingue
|
| 206 |
+
│ ├── generation/ # prompts, appel LLM
|
| 207 |
+
│ ├── optimization/ # cache réponses + embeddings (TTL)
|
| 208 |
+
│ ├── pipeline.py # orchestration retrieval + génération + cache + métriques
|
| 209 |
+
│ ├── metrics/ # journalisation SQLite pour l'Admin
|
| 210 |
+
│ ├── ui/ # onglets Gradio (Chat, Admin, Architecture)
|
| 211 |
+
│ └── assets/architecture.svg # schéma d'architecture
|
| 212 |
+
├── scripts/ingest.py # CLI d'ingestion / publication du dataset
|
| 213 |
+
├── tests/ # pytest (aucun test ne fait d'appel réseau)
|
| 214 |
+
├── data/ # cache local (index, métriques) — gitignored
|
| 215 |
+
└── PolyglotRAG_Deploy_HuggingFace.ipynb
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
## Configuration
|
| 219 |
+
|
| 220 |
+
Toutes les options sont documentées dans `.env.example`. Point clé :
|
| 221 |
+
**un seul jeton, `HF_TOKEN`**, avec le scope *"Make calls to Inference
|
| 222 |
+
Providers"* (+ *"Write"* si le Space doit aussi pousser/rafraîchir le
|
| 223 |
+
dataset). Créez-le sur
|
| 224 |
+
[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens).
|
| 225 |
+
|
| 226 |
+
| Variable | Rôle | Défaut |
|
| 227 |
+
|---|---|---|
|
| 228 |
+
| `HF_TOKEN` | Authentification unique (LLM, embeddings, dataset) | — (obligatoire) |
|
| 229 |
+
| `HF_DATASET_REPO` | Dataset privé contenant l'index | — |
|
| 230 |
+
| `LLM_MODEL` | Modèle génératif | `deepseek-ai/DeepSeek-V4.1-Flash:fastest` |
|
| 231 |
+
| `EMBEDDING_MODEL` | Modèle d'embeddings | `BAAI/bge-m3` |
|
| 232 |
+
| `TOP_K_FINAL` | Passages envoyés au LLM | `2` |
|
| 233 |
+
| `ANN_INDEX_TYPE` | `hnsw` (approximatif) ou `flat` (exact) | `hnsw` |
|
| 234 |
+
| `ENABLE_CACHE` | Cache réponses + embeddings de requêtes | `true` |
|
| 235 |
+
| `CACHE_TTL_SECONDS` | Durée de vie du cache | `600` |
|
| 236 |
+
| `MIN_CONFIDENCE_SCORE` | Seuil d'abstention (mode strict) | `0.28` |
|
| 237 |
+
| `ADMIN_PASSWORD` | Protège l'onglet Admin | vide (accès libre) |
|
| 238 |
+
|
| 239 |
+
## Ingestion de vos propres documents
|
| 240 |
+
|
| 241 |
+
1. Placez vos fichiers dans `knowledge_base/<code_langue>/` (`en`, `fr`, `ru`,
|
| 242 |
+
`es`, `ar`, `pt`) — formats supportés : `.pdf`, `.docx`, `.md`, `.html`,
|
| 243 |
+
`.csv`.
|
| 244 |
+
2. Lancez `python scripts/ingest.py` (pousse automatiquement vers
|
| 245 |
+
`HF_DATASET_REPO` si défini).
|
| 246 |
+
3. Redémarrez le Space (ou rechargez la page) : l'index est reconstruit au
|
| 247 |
+
démarrage à partir du dataset privé.
|
| 248 |
+
|
| 249 |
+
⚠️ Les PDF scannés sans couche texte ne sont pas encore océrisés
|
| 250 |
+
automatiquement — le loader signale les pages concernées
|
| 251 |
+
(`[PAGE SANS TEXTE EXTRACTIBLE — OCR REQUIS]`) plutôt que d'indexer du vide.
|
| 252 |
+
|
| 253 |
+
## Tests
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
pip install pytest
|
| 257 |
+
pytest
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
Tous les tests s'exécutent **hors-ligne** (un garde-fou dans
|
| 261 |
+
`tests/conftest.py` fait échouer volontairement tout test qui tenterait un
|
| 262 |
+
appel réseau réel vers l'API d'embeddings).
|
| 263 |
+
|
| 264 |
+
## Choix d'architecture assumés
|
| 265 |
+
|
| 266 |
+
- **Pas de traduction systématique du corpus.** Chaque chunk garde son
|
| 267 |
+
texte et sa langue d'origine ; seule la recherche passe par un espace
|
| 268 |
+
d'embeddings partagé. La traduction n'intervient jamais côté indexation.
|
| 269 |
+
- **FAISS en mémoire plutôt qu'un service Qdrant externe.** Le dataset privé
|
| 270 |
+
Hugging Face joue le rôle de stockage persistant (Parquet) ; l'index est
|
| 271 |
+
reconstruit en mémoire au démarrage du Space. Pour un corpus de quelques
|
| 272 |
+
dizaines de milliers de chunks, c'est largement suffisant, et cela évite
|
| 273 |
+
une seconde dépendance opérationnelle (donc une seconde clé d'API) — cohérent
|
| 274 |
+
avec la contrainte « un seul jeton Hugging Face ».
|
| 275 |
+
- **DeepSeek-V4.1-Flash via le routeur *Inference Providers*.** Le modèle
|
| 276 |
+
n'est pas déployé/hébergé par ce projet : il est appelé via
|
| 277 |
+
`https://router.huggingface.co`, qui route vers un provider tiers (Novita
|
| 278 |
+
à ce jour) tout en facturant sur votre compte Hugging Face. Le
|
| 279 |
+
qualificatif `:fastest` sur `LLM_MODEL` s'adapte automatiquement si le
|
| 280 |
+
provider change.
|
| 281 |
+
|
| 282 |
+
## Évolutions possibles
|
| 283 |
+
|
| 284 |
+
- Reranking cross-encoder dédié (au lieu du bonus de score actuel).
|
| 285 |
+
- Snapshot périodique de la base de métriques vers le dataset HF (persistance
|
| 286 |
+
au-delà du cycle de vie du Space).
|
| 287 |
+
- Agents spécialisés (routeur de langue, agent de récupération cross-lingue,
|
| 288 |
+
agent terminologie/glossaire métier, agent qualité) pour une architecture
|
| 289 |
+
de type Corrective RAG multi-agent, comme décrit dans le document de
|
| 290 |
+
cadrage du projet.
|
| 291 |
+
- OCR automatique pour les PDF scannés.
|
app.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PolyglotRAG — assistant documentaire multilingue cross-lingue.
|
| 3 |
+
Point d'entrée de l'application Gradio (compatible Hugging Face Spaces,
|
| 4 |
+
SDK "gradio").
|
| 5 |
+
|
| 6 |
+
Onglets :
|
| 7 |
+
- Chat : interface de question/réponse multilingue avec citations.
|
| 8 |
+
- Architecture : schéma du pipeline complet.
|
| 9 |
+
- Admin : métriques du RAG (volume, latence, langues, abstention).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import gradio as gr
|
| 14 |
+
|
| 15 |
+
from core.ui.admin_tab import build_admin_tab
|
| 16 |
+
from core.ui.architecture_tab import build_architecture_tab
|
| 17 |
+
from core.ui.chat_tab import build_chat_tab
|
| 18 |
+
from core.ui.theme import CUSTOM_CSS
|
| 19 |
+
from config import settings
|
| 20 |
+
|
| 21 |
+
HEADER_HTML = """
|
| 22 |
+
<div id="pg-header">
|
| 23 |
+
<h1>🌍 PolyglotRAG</h1>
|
| 24 |
+
<p>Assistant documentaire multilingue cross-lingue — EN · FR · RU · ES · AR (RTL) · PT ·
|
| 25 |
+
propulsé par DeepSeek-V4.1-Flash</p>
|
| 26 |
+
</div>
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_app() -> gr.Blocks:
|
| 31 |
+
with gr.Blocks(
|
| 32 |
+
title="PolyglotRAG",
|
| 33 |
+
css=CUSTOM_CSS,
|
| 34 |
+
theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"),
|
| 35 |
+
) as demo:
|
| 36 |
+
gr.HTML(HEADER_HTML)
|
| 37 |
+
|
| 38 |
+
config_errors = settings.validate()
|
| 39 |
+
if config_errors:
|
| 40 |
+
gr.Markdown(
|
| 41 |
+
"⚠️ **Configuration incomplète** : " + " ".join(config_errors)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
with gr.Tabs():
|
| 45 |
+
with gr.TabItem("💬 Chat"):
|
| 46 |
+
build_chat_tab()
|
| 47 |
+
with gr.TabItem("🏗️ Architecture"):
|
| 48 |
+
build_architecture_tab()
|
| 49 |
+
with gr.TabItem("🔐 Admin"):
|
| 50 |
+
build_admin_tab()
|
| 51 |
+
|
| 52 |
+
return demo
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
demo = build_app()
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
import os
|
| 59 |
+
|
| 60 |
+
# 0.0.0.0 + $PORT (par défaut 7860) : fonctionne à l'identique en local,
|
| 61 |
+
# dans le conteneur Docker fourni, et sur un Hugging Face Space
|
| 62 |
+
# (SDK "gradio" ou "docker" — voir Dockerfile / README).
|
| 63 |
+
demo.queue().launch(
|
| 64 |
+
server_name="0.0.0.0",
|
| 65 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 66 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration centrale de PolyglotRAG.
|
| 3 |
+
|
| 4 |
+
Toutes les valeurs peuvent être surchargées par des variables d'environnement
|
| 5 |
+
(fichier .env en local, "Secrets"/"Variables" sur un Hugging Face Space).
|
| 6 |
+
Le projet n'a besoin que d'UN seul token : HF_TOKEN (droits "Make calls to
|
| 7 |
+
Inference Providers" + lecture/écriture sur le dataset privé).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
load_dotenv()
|
| 17 |
+
except ImportError: # pragma: no cover - python-dotenv est optionnel
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
SUPPORTED_LANGUAGES: dict[str, dict[str, str]] = {
|
| 22 |
+
"en": {"name": "English", "dir": "ltr"},
|
| 23 |
+
"fr": {"name": "Français", "dir": "ltr"},
|
| 24 |
+
"ru": {"name": "Русский", "dir": "ltr"},
|
| 25 |
+
"es": {"name": "Español", "dir": "ltr"},
|
| 26 |
+
"ar": {"name": "العربية", "dir": "rtl"},
|
| 27 |
+
"pt": {"name": "Português", "dir": "ltr"},
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _bool_env(name: str, default: bool) -> bool:
|
| 32 |
+
val = os.getenv(name)
|
| 33 |
+
if val is None:
|
| 34 |
+
return default
|
| 35 |
+
return val.strip().lower() in {"1", "true", "yes", "on"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class Settings:
|
| 40 |
+
# --- Authentification (un seul token pour tout) -----------------------
|
| 41 |
+
hf_token: str = field(default_factory=lambda: os.getenv("HF_TOKEN", ""))
|
| 42 |
+
|
| 43 |
+
# --- Modèle génératif ---------------------------------------------------
|
| 44 |
+
# Servi via le routeur "Inference Providers" de Hugging Face.
|
| 45 |
+
# ":fastest" laisse HF choisir automatiquement le meilleur provider
|
| 46 |
+
# (aujourd'hui Novita) sans jamais changer de code applicatif.
|
| 47 |
+
llm_model: str = field(
|
| 48 |
+
default_factory=lambda: os.getenv(
|
| 49 |
+
"LLM_MODEL", "deepseek-ai/DeepSeek-V4.1-Flash:fastest"
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
llm_temperature: float = field(
|
| 53 |
+
default_factory=lambda: float(os.getenv("LLM_TEMPERATURE", "0.2"))
|
| 54 |
+
)
|
| 55 |
+
llm_max_tokens: int = field(
|
| 56 |
+
default_factory=lambda: int(os.getenv("LLM_MAX_TOKENS", "1024"))
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# --- Modèle d'embeddings --------------------------------------------
|
| 60 |
+
# BGE-M3 = qualité maximale, multilingue + cross-lingue natif (défaut).
|
| 61 |
+
# intfloat/multilingual-e5-base = alternative légère (variable d'env).
|
| 62 |
+
embedding_model: str = field(
|
| 63 |
+
default_factory=lambda: os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
|
| 64 |
+
)
|
| 65 |
+
embedding_dim: int = field(
|
| 66 |
+
default_factory=lambda: int(os.getenv("EMBEDDING_DIM", "1024"))
|
| 67 |
+
)
|
| 68 |
+
use_query_passage_prefix: bool = field(
|
| 69 |
+
default_factory=lambda: _bool_env("USE_QUERY_PASSAGE_PREFIX", False)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# --- Stockage vectoriel (dataset privé Hugging Face) --------------------
|
| 73 |
+
hf_dataset_repo: str = field(
|
| 74 |
+
default_factory=lambda: os.getenv("HF_DATASET_REPO", "")
|
| 75 |
+
)
|
| 76 |
+
local_index_dir: str = field(
|
| 77 |
+
default_factory=lambda: os.getenv("LOCAL_INDEX_DIR", "data/index")
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# --- Chunking -----------------------------------------------------------
|
| 81 |
+
chunk_size_tokens: int = field(
|
| 82 |
+
default_factory=lambda: int(os.getenv("CHUNK_SIZE_TOKENS", "350"))
|
| 83 |
+
)
|
| 84 |
+
chunk_overlap_tokens: int = field(
|
| 85 |
+
default_factory=lambda: int(os.getenv("CHUNK_OVERLAP_TOKENS", "60"))
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# --- Recherche hybride ----------------------------------------------
|
| 89 |
+
top_k_retrieve: int = field(
|
| 90 |
+
default_factory=lambda: int(os.getenv("TOP_K_RETRIEVE", "15"))
|
| 91 |
+
)
|
| 92 |
+
# Volontairement bas par défaut : moins de passages envoyés au LLM ->
|
| 93 |
+
# latence et coût réduits, contexte plus dense. Augmentez si votre
|
| 94 |
+
# corpus a besoin de plus de passages pour couvrir une même réponse.
|
| 95 |
+
top_k_final: int = field(
|
| 96 |
+
default_factory=lambda: int(os.getenv("TOP_K_FINAL", "2"))
|
| 97 |
+
)
|
| 98 |
+
same_language_bonus: float = field(
|
| 99 |
+
default_factory=lambda: float(os.getenv("SAME_LANGUAGE_BONUS", "0.04"))
|
| 100 |
+
)
|
| 101 |
+
min_confidence_score: float = field(
|
| 102 |
+
default_factory=lambda: float(os.getenv("MIN_CONFIDENCE_SCORE", "0.28"))
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# --- Index vectoriel : recherche exacte ou approximative (ANN) ---------
|
| 106 |
+
# "hnsw" (par défaut) = FAISS IndexHNSWFlat, approximatif mais bien plus
|
| 107 |
+
# rapide sur un grand corpus, sans étape d'entraînement (contrairement à
|
| 108 |
+
# IVF). "flat" = recherche exacte (IndexFlatIP), utile pour un tout petit
|
| 109 |
+
# corpus de test ou pour comparer la qualité du retrieval.
|
| 110 |
+
ann_index_type: str = field(
|
| 111 |
+
default_factory=lambda: os.getenv("ANN_INDEX_TYPE", "hnsw")
|
| 112 |
+
)
|
| 113 |
+
ann_hnsw_m: int = field(
|
| 114 |
+
default_factory=lambda: int(os.getenv("ANN_HNSW_M", "32"))
|
| 115 |
+
)
|
| 116 |
+
ann_ef_search: int = field(
|
| 117 |
+
default_factory=lambda: int(os.getenv("ANN_EF_SEARCH", "64"))
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# --- Cache (réponses + embeddings de requêtes) --------------------------
|
| 121 |
+
enable_cache: bool = field(
|
| 122 |
+
default_factory=lambda: _bool_env("ENABLE_CACHE", True)
|
| 123 |
+
)
|
| 124 |
+
cache_ttl_seconds: int = field(
|
| 125 |
+
default_factory=lambda: int(os.getenv("CACHE_TTL_SECONDS", "600"))
|
| 126 |
+
)
|
| 127 |
+
cache_max_size: int = field(
|
| 128 |
+
default_factory=lambda: int(os.getenv("CACHE_MAX_SIZE", "512"))
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# --- Journalisation / métriques ---------------------------------------
|
| 132 |
+
metrics_db_path: str = field(
|
| 133 |
+
default_factory=lambda: os.getenv("METRICS_DB_PATH", "data/metrics.sqlite3")
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# --- Admin ---------------------------------------------------------------
|
| 137 |
+
admin_password: str = field(
|
| 138 |
+
default_factory=lambda: os.getenv("ADMIN_PASSWORD", "")
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def validate(self) -> list[str]:
|
| 142 |
+
errors = []
|
| 143 |
+
if not self.hf_token:
|
| 144 |
+
errors.append(
|
| 145 |
+
"HF_TOKEN manquant : créez un token sur "
|
| 146 |
+
"https://huggingface.co/settings/tokens avec le scope "
|
| 147 |
+
"'Make calls to Inference Providers' (+ 'Write' si vous "
|
| 148 |
+
"poussez un dataset)."
|
| 149 |
+
)
|
| 150 |
+
return errors
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
settings = Settings()
|
core/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PolyglotRAG — assistant documentaire multilingue cross-lingue."""
|
| 2 |
+
__version__ = "1.0.0"
|
core/assets/architecture.svg
ADDED
|
|
core/generation/__init__.py
ADDED
|
File without changes
|
core/generation/answer_generator.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Appel du LLM génératif — DeepSeek-V4.1-Flash — via le routeur "Inference
|
| 3 |
+
Providers" de Hugging Face (https://router.huggingface.co), qui proxy
|
| 4 |
+
automatiquement vers un provider tiers (Novita à ce jour) tout en
|
| 5 |
+
n'exigeant qu'un seul jeton HF_TOKEN pour l'authentification et la
|
| 6 |
+
facturation. Aucune autre clé d'API n'est nécessaire.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
|
| 14 |
+
from core.generation.prompts import build_messages, no_context_message
|
| 15 |
+
from core.retrieval.hybrid_search import RetrievalResult
|
| 16 |
+
from config import settings
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class GenerationResult:
|
| 21 |
+
answer: str
|
| 22 |
+
used_fallback: bool
|
| 23 |
+
latency_seconds: float
|
| 24 |
+
sources: list[dict]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@lru_cache(maxsize=1)
|
| 28 |
+
def _get_client():
|
| 29 |
+
from huggingface_hub import InferenceClient
|
| 30 |
+
|
| 31 |
+
if not settings.hf_token:
|
| 32 |
+
raise RuntimeError("HF_TOKEN manquant : impossible d'appeler le LLM.")
|
| 33 |
+
return InferenceClient(api_key=settings.hf_token)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_answer(
|
| 37 |
+
question: str,
|
| 38 |
+
retrieval: RetrievalResult,
|
| 39 |
+
target_language: str,
|
| 40 |
+
) -> GenerationResult:
|
| 41 |
+
start = time.perf_counter()
|
| 42 |
+
|
| 43 |
+
if not retrieval.is_sufficient:
|
| 44 |
+
return GenerationResult(
|
| 45 |
+
answer=no_context_message(target_language),
|
| 46 |
+
used_fallback=True,
|
| 47 |
+
latency_seconds=time.perf_counter() - start,
|
| 48 |
+
sources=[],
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
messages = build_messages(question, retrieval.hits, target_language)
|
| 52 |
+
client = _get_client()
|
| 53 |
+
|
| 54 |
+
completion = client.chat.completions.create(
|
| 55 |
+
model=settings.llm_model,
|
| 56 |
+
messages=messages,
|
| 57 |
+
temperature=settings.llm_temperature,
|
| 58 |
+
max_tokens=settings.llm_max_tokens,
|
| 59 |
+
)
|
| 60 |
+
answer = completion.choices[0].message.content
|
| 61 |
+
|
| 62 |
+
sources = [
|
| 63 |
+
{
|
| 64 |
+
"file": hit.source_file,
|
| 65 |
+
"language": hit.language,
|
| 66 |
+
"page": hit.page,
|
| 67 |
+
"score": round(hit.score, 3),
|
| 68 |
+
}
|
| 69 |
+
for hit in retrieval.hits
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
return GenerationResult(
|
| 73 |
+
answer=answer,
|
| 74 |
+
used_fallback=False,
|
| 75 |
+
latency_seconds=time.perf_counter() - start,
|
| 76 |
+
sources=sources,
|
| 77 |
+
)
|
core/generation/prompts.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gabarits de prompts pour la génération de réponses PolyglotRAG."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from core.ingestion.language_detector import language_label
|
| 5 |
+
from core.retrieval.vector_store import SearchHit
|
| 6 |
+
|
| 7 |
+
SYSTEM_PROMPT_TEMPLATE = """Tu es PolyglotRAG, un assistant documentaire multilingue.
|
| 8 |
+
|
| 9 |
+
Réponds UNIQUEMENT à partir des extraits de sources fournis ci-dessous.
|
| 10 |
+
Ne crée jamais de faits, de chiffres, de dates ou de règles absents du contexte.
|
| 11 |
+
|
| 12 |
+
Langue demandée pour la réponse : {target_language}
|
| 13 |
+
|
| 14 |
+
Instructions :
|
| 15 |
+
- Réponds naturellement, dans un style professionnel, dans la langue demandée ({target_language}).
|
| 16 |
+
- Les documents sources peuvent être rédigés dans d'autres langues que la réponse.
|
| 17 |
+
- Ne traduis jamais les noms propres, identifiants, références de contrat ou acronymes.
|
| 18 |
+
- Distingue explicitement les faits établis des éléments incertains ou contradictoires entre sources.
|
| 19 |
+
- Si les extraits fournis ne permettent pas de répondre avec certitude, dis-le clairement au lieu de deviner.
|
| 20 |
+
- Termine ta réponse par une liste des sources utilisées : nom de fichier, langue, page.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
NO_CONTEXT_TEMPLATE = {
|
| 24 |
+
"en": "I could not find a sufficiently relevant passage in the knowledge base to answer this question reliably. Could you rephrase it, or would you like me to search across all languages?",
|
| 25 |
+
"fr": "Je n'ai pas trouvé d'extrait suffisamment pertinent dans la base de connaissances pour répondre de façon fiable à cette question. Pouvez-vous la reformuler, ou souhaitez-vous que j'élargisse la recherche à toutes les langues ?",
|
| 26 |
+
"ru": "Мне не удалось найти в базе знаний достаточно релевантный фрагмент, чтобы дать надёжный ответ на этот вопрос. Не могли бы вы переформулировать вопрос, или хотите, чтобы я расширил поиск на все языки?",
|
| 27 |
+
"es": "No he encontrado un pasaje suficientemente relevante en la base de conocimientos para responder con fiabilidad a esta pregunta. ¿Podría reformularla, o desea que amplíe la búsqueda a todos los idiomas?",
|
| 28 |
+
"ar": "لم أتمكن من العثور على مقطع ذي صلة كافية في قاعدة المعرفة للإجابة على هذا السؤال بشكل موثوق. هل يمكنك إعادة صياغته، أم ترغب في أن أوسّع البحث ليشمل جميع اللغات؟",
|
| 29 |
+
"pt": "Não encontrei um trecho suficientemente relevante na base de conhecimento para responder com fiabilidade a esta pergunta. Pode reformulá-la, ou prefere que eu alargue a pesquisa a todos os idiomas?",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def format_context(hits: list[SearchHit]) -> str:
|
| 34 |
+
blocks = []
|
| 35 |
+
for i, hit in enumerate(hits, start=1):
|
| 36 |
+
blocks.append(
|
| 37 |
+
f"[Source {i}]\n"
|
| 38 |
+
f"Langue : {hit.language}\n"
|
| 39 |
+
f"Fichier : {hit.source_file}\n"
|
| 40 |
+
f"Page : {hit.page}\n"
|
| 41 |
+
f"Texte : « {hit.text.strip()} »"
|
| 42 |
+
)
|
| 43 |
+
return "\n\n".join(blocks)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_messages(
|
| 47 |
+
question: str,
|
| 48 |
+
hits: list[SearchHit],
|
| 49 |
+
target_language: str,
|
| 50 |
+
) -> list[dict]:
|
| 51 |
+
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
| 52 |
+
target_language=language_label(target_language)
|
| 53 |
+
)
|
| 54 |
+
context = format_context(hits)
|
| 55 |
+
user_content = f"Question :\n{question}\n\nExtraits :\n{context}"
|
| 56 |
+
return [
|
| 57 |
+
{"role": "system", "content": system_prompt},
|
| 58 |
+
{"role": "user", "content": user_content},
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def no_context_message(target_language: str) -> str:
|
| 63 |
+
return NO_CONTEXT_TEMPLATE.get(target_language, NO_CONTEXT_TEMPLATE["en"])
|
core/ingestion/__init__.py
ADDED
|
File without changes
|
core/ingestion/chunker.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Découpage du texte en chunks adaptés à la langue.
|
| 3 |
+
|
| 4 |
+
Principe : on découpe par phrases/paragraphes (pas par nombre brut de
|
| 5 |
+
caractères) pour ne jamais couper une idée en deux, avec un recouvrement
|
| 6 |
+
("overlap") afin de ne pas perdre le contexte à la frontière entre deux
|
| 7 |
+
chunks. Le découpage par mots (et non uniquement par espaces) fonctionne
|
| 8 |
+
correctement pour l'arabe et le russe car on s'appuie sur les limites de
|
| 9 |
+
phrases Unicode plutôt que sur des heuristiques latines.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
_SENTENCE_SPLIT_RE = re.compile(
|
| 17 |
+
r"(?<=[\.\!\?؟۔。])\s+|\n{2,}"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class Chunk:
|
| 23 |
+
text: str
|
| 24 |
+
index: int
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _approx_token_count(text: str) -> int:
|
| 28 |
+
# Approximation simple et rapide, indépendante de la langue :
|
| 29 |
+
# ~1 token ≈ 0.75 mot pour les langues à espaces, on reste conservateur.
|
| 30 |
+
return max(1, len(text.split()))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def split_into_sentences(text: str) -> list[str]:
|
| 34 |
+
text = text.strip()
|
| 35 |
+
if not text:
|
| 36 |
+
return []
|
| 37 |
+
sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
|
| 38 |
+
return sentences or [text]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def chunk_text(
|
| 42 |
+
text: str,
|
| 43 |
+
chunk_size_tokens: int = 350,
|
| 44 |
+
overlap_tokens: int = 60,
|
| 45 |
+
) -> list[Chunk]:
|
| 46 |
+
"""Découpe `text` en chunks de ~chunk_size_tokens avec recouvrement."""
|
| 47 |
+
sentences = split_into_sentences(text)
|
| 48 |
+
chunks: list[Chunk] = []
|
| 49 |
+
current: list[str] = []
|
| 50 |
+
current_tokens = 0
|
| 51 |
+
|
| 52 |
+
def flush():
|
| 53 |
+
if current:
|
| 54 |
+
chunks.append(Chunk(text=" ".join(current).strip(), index=len(chunks)))
|
| 55 |
+
|
| 56 |
+
for sentence in sentences:
|
| 57 |
+
sentence_tokens = _approx_token_count(sentence)
|
| 58 |
+
|
| 59 |
+
if current_tokens + sentence_tokens > chunk_size_tokens and current:
|
| 60 |
+
flush()
|
| 61 |
+
# Recouvrement : on reprend la fin du chunk précédent.
|
| 62 |
+
overlap_sentences: list[str] = []
|
| 63 |
+
overlap_count = 0
|
| 64 |
+
for s in reversed(current):
|
| 65 |
+
t = _approx_token_count(s)
|
| 66 |
+
if overlap_count + t > overlap_tokens:
|
| 67 |
+
break
|
| 68 |
+
overlap_sentences.insert(0, s)
|
| 69 |
+
overlap_count += t
|
| 70 |
+
current = overlap_sentences
|
| 71 |
+
current_tokens = overlap_count
|
| 72 |
+
|
| 73 |
+
current.append(sentence)
|
| 74 |
+
current_tokens += sentence_tokens
|
| 75 |
+
|
| 76 |
+
flush()
|
| 77 |
+
return chunks or [Chunk(text=text.strip(), index=0)]
|
core/ingestion/indexer.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline d'ingestion complet : dossier `knowledge_base/<lang>/*` -> chunks
|
| 3 |
+
avec métadonnées -> embeddings -> fichier Parquet -> dataset privé Hugging
|
| 4 |
+
Face.
|
| 5 |
+
|
| 6 |
+
Principe clé du projet (voir README) : on N'écrase JAMAIS la langue
|
| 7 |
+
d'origine. Chaque chunk conserve son texte natif, sa langue détectée/
|
| 8 |
+
déclarée, son fichier et sa page — la traduction n'intervient que plus
|
| 9 |
+
tard, côté génération, jamais côté indexation.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import hashlib
|
| 14 |
+
import json
|
| 15 |
+
from dataclasses import asdict, dataclass
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import pandas as pd
|
| 19 |
+
|
| 20 |
+
from core.ingestion.chunker import chunk_text
|
| 21 |
+
from core.ingestion.language_detector import detect_language
|
| 22 |
+
from core.ingestion.loaders import load_file
|
| 23 |
+
from config import settings
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class ChunkRecord:
|
| 28 |
+
chunk_id: str
|
| 29 |
+
document_id: str
|
| 30 |
+
source_file: str
|
| 31 |
+
page: int
|
| 32 |
+
language: str
|
| 33 |
+
title: str | None
|
| 34 |
+
text_direction: str
|
| 35 |
+
text: str
|
| 36 |
+
embedding: list[float]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _document_id(source_file: str, declared_lang: str) -> str:
|
| 40 |
+
stem = Path(source_file).stem
|
| 41 |
+
return f"{declared_lang}_{stem}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _chunk_id(document_id: str, page: int, chunk_index: int) -> str:
|
| 45 |
+
return f"{document_id}_p{page:03d}_c{chunk_index:02d}"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def iter_knowledge_base(kb_dir: Path):
|
| 49 |
+
"""
|
| 50 |
+
Parcourt `kb_dir/<lang_code>/*.{md,pdf,docx,html,csv}`.
|
| 51 |
+
Le sous-dossier donne la langue DÉCLARÉE du document (fiable, fournie
|
| 52 |
+
par l'entreprise) ; on la confronte à la langue détectée automatiquement
|
| 53 |
+
pour repérer les documents mal classés.
|
| 54 |
+
"""
|
| 55 |
+
from config import SUPPORTED_LANGUAGES
|
| 56 |
+
|
| 57 |
+
for lang_dir in sorted(kb_dir.iterdir()):
|
| 58 |
+
if not lang_dir.is_dir() or lang_dir.name not in SUPPORTED_LANGUAGES:
|
| 59 |
+
continue
|
| 60 |
+
declared_lang = lang_dir.name
|
| 61 |
+
for file_path in sorted(lang_dir.rglob("*")):
|
| 62 |
+
if file_path.is_file() and file_path.suffix.lower() in {
|
| 63 |
+
".md", ".markdown", ".html", ".htm", ".csv", ".pdf", ".docx",
|
| 64 |
+
}:
|
| 65 |
+
yield declared_lang, file_path
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def build_chunk_records(
|
| 69 |
+
kb_dir: Path,
|
| 70 |
+
embed_fn=None,
|
| 71 |
+
progress_cb=None,
|
| 72 |
+
) -> list[ChunkRecord]:
|
| 73 |
+
"""
|
| 74 |
+
embed_fn: fonction (list[str]) -> list[list[float]] injectable pour les
|
| 75 |
+
tests (évite tout appel réseau). Par défaut, utilise l'API HF réelle.
|
| 76 |
+
"""
|
| 77 |
+
from config import SUPPORTED_LANGUAGES
|
| 78 |
+
|
| 79 |
+
if embed_fn is None:
|
| 80 |
+
from core.retrieval.embeddings import embed_texts
|
| 81 |
+
|
| 82 |
+
def embed_fn(texts):
|
| 83 |
+
return embed_texts(texts, kind="passage").tolist()
|
| 84 |
+
|
| 85 |
+
all_chunks: list[tuple[ChunkRecord, None]] = []
|
| 86 |
+
pending_texts: list[str] = []
|
| 87 |
+
pending_meta: list[dict] = []
|
| 88 |
+
|
| 89 |
+
for declared_lang, file_path in iter_knowledge_base(kb_dir):
|
| 90 |
+
pages = load_file(file_path)
|
| 91 |
+
document_id = _document_id(file_path.name, declared_lang)
|
| 92 |
+
text_direction = SUPPORTED_LANGUAGES[declared_lang]["dir"]
|
| 93 |
+
|
| 94 |
+
for page in pages:
|
| 95 |
+
detected_lang, confidence = detect_language(page.text)
|
| 96 |
+
# La langue déclarée (dossier) fait foi ; on journalise un écart
|
| 97 |
+
# éventuel pour audit qualité plutôt que de rejeter le document.
|
| 98 |
+
final_lang = declared_lang
|
| 99 |
+
|
| 100 |
+
for chunk in chunk_text(
|
| 101 |
+
page.text,
|
| 102 |
+
chunk_size_tokens=settings.chunk_size_tokens,
|
| 103 |
+
overlap_tokens=settings.chunk_overlap_tokens,
|
| 104 |
+
):
|
| 105 |
+
if not chunk.text.strip():
|
| 106 |
+
continue
|
| 107 |
+
chunk_id = _chunk_id(document_id, page.page, chunk.index)
|
| 108 |
+
pending_texts.append(chunk.text)
|
| 109 |
+
pending_meta.append(
|
| 110 |
+
dict(
|
| 111 |
+
chunk_id=chunk_id,
|
| 112 |
+
document_id=document_id,
|
| 113 |
+
source_file=file_path.name,
|
| 114 |
+
page=page.page,
|
| 115 |
+
language=final_lang,
|
| 116 |
+
title=page.title,
|
| 117 |
+
text_direction=text_direction,
|
| 118 |
+
text=chunk.text,
|
| 119 |
+
detected_language=detected_lang,
|
| 120 |
+
detection_confidence=round(confidence, 3),
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if progress_cb:
|
| 125 |
+
progress_cb(file_path.name, declared_lang)
|
| 126 |
+
|
| 127 |
+
# Embeddings par lot (plus rapide, moins d'appels réseau).
|
| 128 |
+
records: list[ChunkRecord] = []
|
| 129 |
+
batch_size = 32
|
| 130 |
+
for i in range(0, len(pending_texts), batch_size):
|
| 131 |
+
batch_texts = pending_texts[i : i + batch_size]
|
| 132 |
+
batch_meta = pending_meta[i : i + batch_size]
|
| 133 |
+
vectors = embed_fn(batch_texts)
|
| 134 |
+
for meta, vector in zip(batch_meta, vectors):
|
| 135 |
+
meta_copy = dict(meta)
|
| 136 |
+
detected_language = meta_copy.pop("detected_language")
|
| 137 |
+
detection_confidence = meta_copy.pop("detection_confidence")
|
| 138 |
+
records.append(
|
| 139 |
+
ChunkRecord(embedding=list(vector), **meta_copy)
|
| 140 |
+
)
|
| 141 |
+
# écart de langue -> log simple sur stdout (visible en ingestion)
|
| 142 |
+
if detected_language not in ("unknown", meta_copy["language"]):
|
| 143 |
+
print(
|
| 144 |
+
f"[audit langue] {meta_copy['chunk_id']}: dossier="
|
| 145 |
+
f"{meta_copy['language']} vs détecté={detected_language} "
|
| 146 |
+
f"(confiance={detection_confidence})"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return records
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def records_to_dataframe(records: list[ChunkRecord]) -> pd.DataFrame:
|
| 153 |
+
return pd.DataFrame([asdict(r) for r in records])
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def save_index_locally(records: list[ChunkRecord], out_dir: Path) -> Path:
|
| 157 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 158 |
+
df = records_to_dataframe(records)
|
| 159 |
+
parquet_path = out_dir / "chunks.parquet"
|
| 160 |
+
df.to_parquet(parquet_path, index=False)
|
| 161 |
+
|
| 162 |
+
stats = {
|
| 163 |
+
"total_chunks": len(records),
|
| 164 |
+
"by_language": df["language"].value_counts().to_dict() if len(df) else {},
|
| 165 |
+
"embedding_model": settings.embedding_model,
|
| 166 |
+
"embedding_dim": settings.embedding_dim,
|
| 167 |
+
}
|
| 168 |
+
(out_dir / "manifest.json").write_text(
|
| 169 |
+
json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 170 |
+
)
|
| 171 |
+
return parquet_path
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def push_index_to_hub(local_dir: Path, repo_id: str, token: str) -> str:
|
| 175 |
+
"""Pousse chunks.parquet + manifest.json vers un dataset privé HF."""
|
| 176 |
+
from huggingface_hub import HfApi
|
| 177 |
+
|
| 178 |
+
api = HfApi(token=token)
|
| 179 |
+
api.create_repo(
|
| 180 |
+
repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True
|
| 181 |
+
)
|
| 182 |
+
api.upload_folder(
|
| 183 |
+
folder_path=str(local_dir),
|
| 184 |
+
repo_id=repo_id,
|
| 185 |
+
repo_type="dataset",
|
| 186 |
+
allow_patterns=["*.parquet", "*.json"],
|
| 187 |
+
commit_message="Mise à jour de l'index PolyglotRAG",
|
| 188 |
+
)
|
| 189 |
+
return f"https://huggingface.co/datasets/{repo_id}"
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def fingerprint_kb(kb_dir: Path) -> str:
|
| 193 |
+
"""Empreinte simple du corpus (pour savoir si une ré-ingestion est utile)."""
|
| 194 |
+
h = hashlib.sha256()
|
| 195 |
+
for _, file_path in iter_knowledge_base(kb_dir):
|
| 196 |
+
h.update(file_path.name.encode())
|
| 197 |
+
h.update(str(file_path.stat().st_mtime_ns).encode())
|
| 198 |
+
return h.hexdigest()[:16]
|
core/ingestion/language_detector.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Détection de la langue d'un texte (document ou question utilisateur).
|
| 3 |
+
|
| 4 |
+
Utilise `lingua-language-detector`, qui fonctionne hors-ligne (aucun appel
|
| 5 |
+
API), gère bien les 6 langues du projet et fournit un score de confiance —
|
| 6 |
+
utile pour laisser l'utilisateur corriger la langue détectée en cas de
|
| 7 |
+
texte très court ou ambigu (ex : « OK », un simple identifiant, etc.).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
|
| 13 |
+
from config import SUPPORTED_LANGUAGES
|
| 14 |
+
|
| 15 |
+
_LANGUAGE_NAME_TO_ISO = {
|
| 16 |
+
"ENGLISH": "en",
|
| 17 |
+
"FRENCH": "fr",
|
| 18 |
+
"RUSSIAN": "ru",
|
| 19 |
+
"SPANISH": "es",
|
| 20 |
+
"ARABIC": "ar",
|
| 21 |
+
"PORTUGUESE": "pt",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@lru_cache(maxsize=1)
|
| 26 |
+
def _get_detector():
|
| 27 |
+
from lingua import Language, LanguageDetectorBuilder
|
| 28 |
+
|
| 29 |
+
langs = [getattr(Language, name) for name in _LANGUAGE_NAME_TO_ISO]
|
| 30 |
+
return LanguageDetectorBuilder.from_languages(*langs).build()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def detect_language(text: str, min_confidence: float = 0.20) -> tuple[str, float]:
|
| 34 |
+
"""
|
| 35 |
+
Retourne (code_iso, confiance). code_iso == "unknown" si la confiance
|
| 36 |
+
est trop faible ou si le texte est vide/trop court pour être fiable.
|
| 37 |
+
"""
|
| 38 |
+
clean = (text or "").strip()
|
| 39 |
+
if len(clean) < 2:
|
| 40 |
+
return "unknown", 0.0
|
| 41 |
+
|
| 42 |
+
detector = _get_detector()
|
| 43 |
+
confidence_values = detector.compute_language_confidence_values(clean)
|
| 44 |
+
if not confidence_values:
|
| 45 |
+
return "unknown", 0.0
|
| 46 |
+
|
| 47 |
+
best = confidence_values[0]
|
| 48 |
+
iso = _LANGUAGE_NAME_TO_ISO.get(best.language.name, "unknown")
|
| 49 |
+
|
| 50 |
+
if best.value < min_confidence or iso not in SUPPORTED_LANGUAGES:
|
| 51 |
+
return "unknown", float(best.value)
|
| 52 |
+
|
| 53 |
+
return iso, float(best.value)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def language_label(iso_code: str) -> str:
|
| 57 |
+
info = SUPPORTED_LANGUAGES.get(iso_code)
|
| 58 |
+
return info["name"] if info else iso_code
|
core/ingestion/loaders.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extraction de texte à partir de fichiers PDF, DOCX, Markdown, HTML et CSV.
|
| 3 |
+
|
| 4 |
+
Chaque loader retourne une liste de `RawPage` : une unité (page ou ligne)
|
| 5 |
+
avec son texte brut et le numéro de page quand la notion existe, afin de
|
| 6 |
+
pouvoir citer précisément la source (fichier + page) plus tard.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import csv
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class RawPage:
|
| 17 |
+
text: str
|
| 18 |
+
page: int
|
| 19 |
+
title: str | None = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_markdown(path: Path) -> list[RawPage]:
|
| 23 |
+
text = path.read_text(encoding="utf-8")
|
| 24 |
+
title = None
|
| 25 |
+
for line in text.splitlines():
|
| 26 |
+
stripped = line.strip()
|
| 27 |
+
if stripped.startswith("# "):
|
| 28 |
+
title = stripped.lstrip("#").strip()
|
| 29 |
+
break
|
| 30 |
+
return [RawPage(text=text, page=1, title=title)]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_html(path: Path) -> list[RawPage]:
|
| 34 |
+
from bs4 import BeautifulSoup
|
| 35 |
+
|
| 36 |
+
html = path.read_text(encoding="utf-8", errors="ignore")
|
| 37 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 38 |
+
title_tag = soup.find("title") or soup.find("h1")
|
| 39 |
+
title = title_tag.get_text(strip=True) if title_tag else None
|
| 40 |
+
text = soup.get_text(separator="\n")
|
| 41 |
+
return [RawPage(text=text, page=1, title=title)]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_csv(path: Path) -> list[RawPage]:
|
| 45 |
+
rows_text = []
|
| 46 |
+
with path.open(encoding="utf-8", errors="ignore", newline="") as fh:
|
| 47 |
+
reader = csv.reader(fh)
|
| 48 |
+
header = next(reader, None)
|
| 49 |
+
for i, row in enumerate(reader, start=1):
|
| 50 |
+
if header:
|
| 51 |
+
row_text = "; ".join(
|
| 52 |
+
f"{h.strip()}: {v.strip()}" for h, v in zip(header, row)
|
| 53 |
+
)
|
| 54 |
+
else:
|
| 55 |
+
row_text = ", ".join(row)
|
| 56 |
+
rows_text.append(row_text)
|
| 57 |
+
# On regroupe les lignes par blocs de 40 pour éviter des chunks minuscules.
|
| 58 |
+
pages = []
|
| 59 |
+
block = 40
|
| 60 |
+
for i in range(0, len(rows_text), block):
|
| 61 |
+
chunk = "\n".join(rows_text[i : i + block])
|
| 62 |
+
pages.append(RawPage(text=chunk, page=(i // block) + 1, title=path.stem))
|
| 63 |
+
return pages or [RawPage(text="", page=1, title=path.stem)]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def load_pdf(path: Path) -> list[RawPage]:
|
| 67 |
+
from pypdf import PdfReader
|
| 68 |
+
|
| 69 |
+
reader = PdfReader(str(path))
|
| 70 |
+
pages = []
|
| 71 |
+
title = None
|
| 72 |
+
meta_title = getattr(reader, "metadata", None)
|
| 73 |
+
if meta_title and getattr(meta_title, "title", None):
|
| 74 |
+
title = meta_title.title
|
| 75 |
+
for i, page in enumerate(reader.pages, start=1):
|
| 76 |
+
text = page.extract_text() or ""
|
| 77 |
+
if not text.strip():
|
| 78 |
+
# Page probablement scannée sans OCR préalable : on le signale
|
| 79 |
+
# plutôt que d'indexer une page vide silencieusement.
|
| 80 |
+
text = "[PAGE SANS TEXTE EXTRACTIBLE — OCR REQUIS]"
|
| 81 |
+
pages.append(RawPage(text=text, page=i, title=title))
|
| 82 |
+
return pages
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_docx(path: Path) -> list[RawPage]:
|
| 86 |
+
import docx
|
| 87 |
+
|
| 88 |
+
document = docx.Document(str(path))
|
| 89 |
+
title = None
|
| 90 |
+
paragraphs = []
|
| 91 |
+
for para in document.paragraphs:
|
| 92 |
+
if not title and para.style and para.style.name.lower().startswith("heading"):
|
| 93 |
+
title = para.text.strip()
|
| 94 |
+
if para.text.strip():
|
| 95 |
+
paragraphs.append(para.text)
|
| 96 |
+
text = "\n".join(paragraphs)
|
| 97 |
+
return [RawPage(text=text, page=1, title=title or path.stem)]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
LOADERS = {
|
| 101 |
+
".md": load_markdown,
|
| 102 |
+
".markdown": load_markdown,
|
| 103 |
+
".html": load_html,
|
| 104 |
+
".htm": load_html,
|
| 105 |
+
".csv": load_csv,
|
| 106 |
+
".pdf": load_pdf,
|
| 107 |
+
".docx": load_docx,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def load_file(path: Path) -> list[RawPage]:
|
| 112 |
+
"""Route un fichier vers le loader adapté à son extension."""
|
| 113 |
+
loader = LOADERS.get(path.suffix.lower())
|
| 114 |
+
if loader is None:
|
| 115 |
+
raise ValueError(
|
| 116 |
+
f"Format non supporté pour '{path.name}' "
|
| 117 |
+
f"(extensions supportées : {', '.join(LOADERS)})"
|
| 118 |
+
)
|
| 119 |
+
return loader(path)
|
core/metrics/__init__.py
ADDED
|
File without changes
|
core/metrics/logger.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Journalisation des requêtes RAG dans une base SQLite locale, pour
|
| 3 |
+
alimenter la page Admin (page:metrics) : volume par langue, latence,
|
| 4 |
+
score de confiance moyen, taux d'abstention, paires de langues
|
| 5 |
+
cross-lingues (langue question -> langue des sources utilisées), etc.
|
| 6 |
+
|
| 7 |
+
SQLite est suffisant pour un tableau de bord mono-instance ; la base
|
| 8 |
+
peut être snapshotée périodiquement vers le dataset HF privé si l'on
|
| 9 |
+
veut la persister au-delà du cycle de vie du Space (voir
|
| 10 |
+
`snapshot_to_hub`).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import sqlite3
|
| 16 |
+
import time
|
| 17 |
+
from contextlib import contextmanager
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from config import settings
|
| 21 |
+
|
| 22 |
+
_SCHEMA = """
|
| 23 |
+
CREATE TABLE IF NOT EXISTS queries (
|
| 24 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
+
timestamp REAL NOT NULL,
|
| 26 |
+
question TEXT NOT NULL,
|
| 27 |
+
query_language TEXT,
|
| 28 |
+
target_language TEXT,
|
| 29 |
+
confidence_score REAL,
|
| 30 |
+
is_sufficient INTEGER,
|
| 31 |
+
used_fallback INTEGER,
|
| 32 |
+
retrieval_latency_ms REAL,
|
| 33 |
+
generation_latency_ms REAL,
|
| 34 |
+
total_latency_ms REAL,
|
| 35 |
+
sources_json TEXT,
|
| 36 |
+
source_languages_json TEXT
|
| 37 |
+
);
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _db_path() -> Path:
|
| 42 |
+
path = Path(settings.metrics_db_path)
|
| 43 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
return path
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@contextmanager
|
| 48 |
+
def _connect():
|
| 49 |
+
conn = sqlite3.connect(_db_path())
|
| 50 |
+
try:
|
| 51 |
+
conn.execute(_SCHEMA)
|
| 52 |
+
yield conn
|
| 53 |
+
conn.commit()
|
| 54 |
+
finally:
|
| 55 |
+
conn.close()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def log_query(
|
| 59 |
+
question: str,
|
| 60 |
+
query_language: str,
|
| 61 |
+
target_language: str,
|
| 62 |
+
confidence_score: float,
|
| 63 |
+
is_sufficient: bool,
|
| 64 |
+
used_fallback: bool,
|
| 65 |
+
retrieval_latency_ms: float,
|
| 66 |
+
generation_latency_ms: float,
|
| 67 |
+
sources: list[dict],
|
| 68 |
+
) -> None:
|
| 69 |
+
source_languages = sorted({s["language"] for s in sources}) if sources else []
|
| 70 |
+
with _connect() as conn:
|
| 71 |
+
conn.execute(
|
| 72 |
+
"""
|
| 73 |
+
INSERT INTO queries (
|
| 74 |
+
timestamp, question, query_language, target_language,
|
| 75 |
+
confidence_score, is_sufficient, used_fallback,
|
| 76 |
+
retrieval_latency_ms, generation_latency_ms, total_latency_ms,
|
| 77 |
+
sources_json, source_languages_json
|
| 78 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 79 |
+
""",
|
| 80 |
+
(
|
| 81 |
+
time.time(),
|
| 82 |
+
question[:2000],
|
| 83 |
+
query_language,
|
| 84 |
+
target_language,
|
| 85 |
+
confidence_score,
|
| 86 |
+
int(is_sufficient),
|
| 87 |
+
int(used_fallback),
|
| 88 |
+
retrieval_latency_ms,
|
| 89 |
+
generation_latency_ms,
|
| 90 |
+
retrieval_latency_ms + generation_latency_ms,
|
| 91 |
+
json.dumps(sources, ensure_ascii=False),
|
| 92 |
+
json.dumps(source_languages, ensure_ascii=False),
|
| 93 |
+
),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def fetch_recent(limit: int = 200) -> list[dict]:
|
| 98 |
+
with _connect() as conn:
|
| 99 |
+
conn.row_factory = sqlite3.Row
|
| 100 |
+
rows = conn.execute(
|
| 101 |
+
"SELECT * FROM queries ORDER BY timestamp DESC LIMIT ?", (limit,)
|
| 102 |
+
).fetchall()
|
| 103 |
+
return [dict(r) for r in rows]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def summary_metrics() -> dict:
|
| 107 |
+
"""Agrège les métriques utilisées par l'onglet Admin."""
|
| 108 |
+
with _connect() as conn:
|
| 109 |
+
conn.row_factory = sqlite3.Row
|
| 110 |
+
total = conn.execute("SELECT COUNT(*) AS n FROM queries").fetchone()["n"]
|
| 111 |
+
if total == 0:
|
| 112 |
+
return {
|
| 113 |
+
"total_queries": 0,
|
| 114 |
+
"abstention_rate": 0.0,
|
| 115 |
+
"avg_confidence": 0.0,
|
| 116 |
+
"avg_latency_ms": 0.0,
|
| 117 |
+
"by_query_language": {},
|
| 118 |
+
"by_target_language": {},
|
| 119 |
+
"cross_lingual_rate": 0.0,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
abstentions = conn.execute(
|
| 123 |
+
"SELECT COUNT(*) AS n FROM queries WHERE used_fallback = 1"
|
| 124 |
+
).fetchone()["n"]
|
| 125 |
+
avg_conf = conn.execute(
|
| 126 |
+
"SELECT AVG(confidence_score) AS v FROM queries"
|
| 127 |
+
).fetchone()["v"]
|
| 128 |
+
avg_latency = conn.execute(
|
| 129 |
+
"SELECT AVG(total_latency_ms) AS v FROM queries"
|
| 130 |
+
).fetchone()["v"]
|
| 131 |
+
|
| 132 |
+
by_query_lang = {
|
| 133 |
+
row["query_language"]: row["n"]
|
| 134 |
+
for row in conn.execute(
|
| 135 |
+
"SELECT query_language, COUNT(*) AS n FROM queries "
|
| 136 |
+
"GROUP BY query_language"
|
| 137 |
+
).fetchall()
|
| 138 |
+
}
|
| 139 |
+
by_target_lang = {
|
| 140 |
+
row["target_language"]: row["n"]
|
| 141 |
+
for row in conn.execute(
|
| 142 |
+
"SELECT target_language, COUNT(*) AS n FROM queries "
|
| 143 |
+
"GROUP BY target_language"
|
| 144 |
+
).fetchall()
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
# Taux de requêtes cross-lingues : langue question absente des
|
| 148 |
+
# langues sources utilisées (preuve que la recherche cross-lingue
|
| 149 |
+
# a effectivement servi, et pas seulement une recherche mono-langue).
|
| 150 |
+
rows = conn.execute(
|
| 151 |
+
"SELECT query_language, source_languages_json FROM queries "
|
| 152 |
+
"WHERE used_fallback = 0"
|
| 153 |
+
).fetchall()
|
| 154 |
+
cross_count = 0
|
| 155 |
+
counted = 0
|
| 156 |
+
for row in rows:
|
| 157 |
+
src_langs = json.loads(row["source_languages_json"] or "[]")
|
| 158 |
+
if not src_langs:
|
| 159 |
+
continue
|
| 160 |
+
counted += 1
|
| 161 |
+
if row["query_language"] not in src_langs:
|
| 162 |
+
cross_count += 1
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
"total_queries": total,
|
| 166 |
+
"abstention_rate": round(abstentions / total, 3),
|
| 167 |
+
"avg_confidence": round(avg_conf or 0.0, 3),
|
| 168 |
+
"avg_latency_ms": round(avg_latency or 0.0, 1),
|
| 169 |
+
"by_query_language": by_query_lang,
|
| 170 |
+
"by_target_language": by_target_lang,
|
| 171 |
+
"cross_lingual_rate": round(cross_count / counted, 3) if counted else 0.0,
|
| 172 |
+
}
|
core/optimization/__init__.py
ADDED
|
File without changes
|
core/optimization/cache.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cache générique (TTL + taille bornée) partagé par tout le pipeline.
|
| 3 |
+
|
| 4 |
+
Deux usages concrets dans PolyglotRAG :
|
| 5 |
+
1. `embedding_cache` : évite de rappeler l'API d'embeddings pour une même
|
| 6 |
+
question déjà posée récemment (économie d'appels réseau + latence).
|
| 7 |
+
2. `answer_cache` (utilisé par `core.pipeline`) : évite de refaire tout le
|
| 8 |
+
cycle retrieval + génération LLM pour une question strictement
|
| 9 |
+
identique posée à nouveau pendant la fenêtre de TTL.
|
| 10 |
+
|
| 11 |
+
Activable/désactivable via `ENABLE_CACHE` (config.py) ; désactivé, chaque
|
| 12 |
+
`get()` renvoie toujours None et `set()` ne fait rien, sans branche
|
| 13 |
+
conditionnelle à ajouter dans le code appelant.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import threading
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from cachetools import TTLCache
|
| 21 |
+
|
| 22 |
+
from config import settings
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SimpleCache:
|
| 26 |
+
def __init__(self, maxsize: int, ttl: int, enabled: bool = True) -> None:
|
| 27 |
+
self._enabled = enabled
|
| 28 |
+
self._lock = threading.Lock()
|
| 29 |
+
self._store: TTLCache | None = TTLCache(maxsize=maxsize, ttl=ttl) if enabled else None
|
| 30 |
+
|
| 31 |
+
def get(self, key: str) -> Any | None:
|
| 32 |
+
if not self._enabled:
|
| 33 |
+
return None
|
| 34 |
+
with self._lock:
|
| 35 |
+
return self._store.get(key)
|
| 36 |
+
|
| 37 |
+
def set(self, key: str, value: Any) -> None:
|
| 38 |
+
if not self._enabled:
|
| 39 |
+
return
|
| 40 |
+
with self._lock:
|
| 41 |
+
self._store[key] = value
|
| 42 |
+
|
| 43 |
+
def clear(self) -> None:
|
| 44 |
+
if self._enabled:
|
| 45 |
+
with self._lock:
|
| 46 |
+
self._store.clear()
|
| 47 |
+
|
| 48 |
+
def stats(self) -> dict:
|
| 49 |
+
if not self._enabled:
|
| 50 |
+
return {"enabled": False, "size": 0, "maxsize": 0}
|
| 51 |
+
with self._lock:
|
| 52 |
+
return {
|
| 53 |
+
"enabled": True,
|
| 54 |
+
"size": len(self._store),
|
| 55 |
+
"maxsize": self._store.maxsize,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def make_cache_key(*parts: str) -> str:
|
| 60 |
+
return "||".join(parts)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# Deux caches distincts (tailles/TTL identiques par défaut, mais séparés
|
| 64 |
+
# pour ne pas mélanger des vecteurs d'embeddings et des réponses complètes).
|
| 65 |
+
embedding_cache = SimpleCache(
|
| 66 |
+
maxsize=settings.cache_max_size, ttl=settings.cache_ttl_seconds, enabled=settings.enable_cache
|
| 67 |
+
)
|
| 68 |
+
answer_cache = SimpleCache(
|
| 69 |
+
maxsize=settings.cache_max_size, ttl=settings.cache_ttl_seconds, enabled=settings.enable_cache
|
| 70 |
+
)
|
core/pipeline.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Orchestration bout-en-bout d'une question utilisateur.
|
| 3 |
+
|
| 4 |
+
Centralise les optimisations du projet, pour qu'elles ne soient pas
|
| 5 |
+
éparpillées dans la couche UI :
|
| 6 |
+
|
| 7 |
+
1. **Cache réponse** : une question strictement identique (même langue de
|
| 8 |
+
réponse, même filtre de langue) posée pendant la fenêtre de TTL renvoie
|
| 9 |
+
instantanément le résultat déjà calculé, sans repasser ni par la
|
| 10 |
+
recherche vectorielle ni par le LLM.
|
| 11 |
+
2. **Cache embedding** (voir `core.retrieval.embeddings.embed_query`) :
|
| 12 |
+
complémentaire, utile dès que la formulation de la question change
|
| 13 |
+
mais que le cache réponse ne peut pas s'appliquer.
|
| 14 |
+
3. **Recherche approximative (ANN)** : déléguée à `VectorStore`
|
| 15 |
+
(FAISS HNSW par défaut, voir `core.retrieval.vector_store`).
|
| 16 |
+
4. **Top-k final réduit** : `settings.top_k_final` (2 par défaut) limite
|
| 17 |
+
le nombre de passages effectivement envoyés au LLM.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import time
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
|
| 24 |
+
from core.generation.answer_generator import GenerationResult, generate_answer
|
| 25 |
+
from core.metrics.logger import log_query
|
| 26 |
+
from core.optimization.cache import answer_cache, make_cache_key
|
| 27 |
+
from core.retrieval.hybrid_search import RetrievalResult, retrieve
|
| 28 |
+
from core.retrieval.vector_store import VectorStore
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class PipelineResult:
|
| 33 |
+
generation: GenerationResult
|
| 34 |
+
retrieval: RetrievalResult
|
| 35 |
+
target_language: str
|
| 36 |
+
retrieval_latency_ms: float
|
| 37 |
+
from_cache: bool
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def ask(
|
| 41 |
+
question: str,
|
| 42 |
+
store: VectorStore,
|
| 43 |
+
response_language_choice: str,
|
| 44 |
+
language_filter: str | None,
|
| 45 |
+
) -> PipelineResult:
|
| 46 |
+
cache_key = make_cache_key(
|
| 47 |
+
question.strip().lower(), response_language_choice, language_filter or "all"
|
| 48 |
+
)
|
| 49 |
+
cached = answer_cache.get(cache_key)
|
| 50 |
+
if cached is not None:
|
| 51 |
+
retrieval, generation, target_language, retrieval_latency_ms = cached
|
| 52 |
+
# On journalise même les réponses servies depuis le cache : le
|
| 53 |
+
# tableau de bord Admin doit refléter le volume RÉEL de questions
|
| 54 |
+
# posées, pas seulement les questions qui ont déclenché un appel LLM.
|
| 55 |
+
log_query(
|
| 56 |
+
question=question,
|
| 57 |
+
query_language=retrieval.query_language,
|
| 58 |
+
target_language=target_language,
|
| 59 |
+
confidence_score=retrieval.confidence_score,
|
| 60 |
+
is_sufficient=retrieval.is_sufficient,
|
| 61 |
+
used_fallback=generation.used_fallback,
|
| 62 |
+
retrieval_latency_ms=0.0,
|
| 63 |
+
generation_latency_ms=0.0,
|
| 64 |
+
sources=generation.sources,
|
| 65 |
+
)
|
| 66 |
+
return PipelineResult(
|
| 67 |
+
generation=generation,
|
| 68 |
+
retrieval=retrieval,
|
| 69 |
+
target_language=target_language,
|
| 70 |
+
retrieval_latency_ms=retrieval_latency_ms,
|
| 71 |
+
from_cache=True,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
t0 = time.perf_counter()
|
| 75 |
+
retrieval = retrieve(question, store, language_filter=language_filter)
|
| 76 |
+
retrieval_latency_ms = (time.perf_counter() - t0) * 1000
|
| 77 |
+
|
| 78 |
+
target_language = (
|
| 79 |
+
retrieval.query_language if response_language_choice == "auto" else response_language_choice
|
| 80 |
+
)
|
| 81 |
+
if target_language == "unknown":
|
| 82 |
+
target_language = "en"
|
| 83 |
+
|
| 84 |
+
generation = generate_answer(question, retrieval, target_language)
|
| 85 |
+
|
| 86 |
+
log_query(
|
| 87 |
+
question=question,
|
| 88 |
+
query_language=retrieval.query_language,
|
| 89 |
+
target_language=target_language,
|
| 90 |
+
confidence_score=retrieval.confidence_score,
|
| 91 |
+
is_sufficient=retrieval.is_sufficient,
|
| 92 |
+
used_fallback=generation.used_fallback,
|
| 93 |
+
retrieval_latency_ms=retrieval_latency_ms,
|
| 94 |
+
generation_latency_ms=generation.latency_seconds * 1000,
|
| 95 |
+
sources=generation.sources,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
answer_cache.set(cache_key, (retrieval, generation, target_language, retrieval_latency_ms))
|
| 99 |
+
|
| 100 |
+
return PipelineResult(
|
| 101 |
+
generation=generation,
|
| 102 |
+
retrieval=retrieval,
|
| 103 |
+
target_language=target_language,
|
| 104 |
+
retrieval_latency_ms=retrieval_latency_ms,
|
| 105 |
+
from_cache=False,
|
| 106 |
+
)
|
core/retrieval/__init__.py
ADDED
|
File without changes
|
core/retrieval/embeddings.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Génération d'embeddings multilingues via l'API "Inference Providers" de
|
| 3 |
+
Hugging Face (provider `hf-inference`) — un seul jeton (HF_TOKEN) suffit,
|
| 4 |
+
aucun compte tiers requis.
|
| 5 |
+
|
| 6 |
+
Modèle par défaut : BAAI/bge-m3 (>100 langues, recherche cross-lingue native
|
| 7 |
+
dans un espace sémantique partagé). Alternative légère configurable :
|
| 8 |
+
intfloat/multilingual-e5-base (nécessite les préfixes "query:"/"passage:").
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import time
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from config import settings
|
| 18 |
+
from core.optimization.cache import embedding_cache, make_cache_key
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@lru_cache(maxsize=1)
|
| 22 |
+
def _get_client():
|
| 23 |
+
from huggingface_hub import InferenceClient
|
| 24 |
+
|
| 25 |
+
if not settings.hf_token:
|
| 26 |
+
raise RuntimeError(
|
| 27 |
+
"HF_TOKEN manquant : impossible d'appeler l'API d'embeddings."
|
| 28 |
+
)
|
| 29 |
+
return InferenceClient(provider="hf-inference", api_key=settings.hf_token)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _prefix(texts: list[str], kind: str) -> list[str]:
|
| 33 |
+
if not settings.use_query_passage_prefix:
|
| 34 |
+
return texts
|
| 35 |
+
tag = "query:" if kind == "query" else "passage:"
|
| 36 |
+
return [f"{tag} {t}" for t in texts]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _normalize(vectors: np.ndarray) -> np.ndarray:
|
| 40 |
+
norms = np.linalg.norm(vectors, axis=-1, keepdims=True)
|
| 41 |
+
norms[norms == 0] = 1.0
|
| 42 |
+
return vectors / norms
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def embed_texts(
|
| 46 |
+
texts: list[str],
|
| 47 |
+
kind: str = "passage",
|
| 48 |
+
max_retries: int = 3,
|
| 49 |
+
) -> np.ndarray:
|
| 50 |
+
"""
|
| 51 |
+
Encode une liste de textes en vecteurs normalisés (similarité cosinus).
|
| 52 |
+
`kind` ∈ {"passage", "query"} détermine le préfixe éventuel.
|
| 53 |
+
"""
|
| 54 |
+
if not texts:
|
| 55 |
+
return np.zeros((0, settings.embedding_dim), dtype="float32")
|
| 56 |
+
|
| 57 |
+
client = _get_client()
|
| 58 |
+
prefixed = _prefix(texts, kind)
|
| 59 |
+
|
| 60 |
+
last_error: Exception | None = None
|
| 61 |
+
for attempt in range(max_retries):
|
| 62 |
+
try:
|
| 63 |
+
result = client.feature_extraction(
|
| 64 |
+
prefixed, model=settings.embedding_model
|
| 65 |
+
)
|
| 66 |
+
vectors = np.array(result, dtype="float32")
|
| 67 |
+
# Certains providers renvoient (n, seq_len, dim) -> mean pooling.
|
| 68 |
+
if vectors.ndim == 3:
|
| 69 |
+
vectors = vectors.mean(axis=1)
|
| 70 |
+
return _normalize(vectors)
|
| 71 |
+
except Exception as exc: # noqa: BLE001 - on veut logguer puis retenter
|
| 72 |
+
last_error = exc
|
| 73 |
+
time.sleep(1.5 * (attempt + 1))
|
| 74 |
+
|
| 75 |
+
raise RuntimeError(
|
| 76 |
+
f"Échec de l'appel d'embeddings après {max_retries} tentatives : {last_error}"
|
| 77 |
+
) from last_error
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def embed_query(text: str) -> np.ndarray:
|
| 81 |
+
"""
|
| 82 |
+
Comme `embed_texts`, mais met en cache le vecteur d'une question déjà
|
| 83 |
+
posée récemment (même modèle d'embeddings) : une question répétée par
|
| 84 |
+
plusieurs utilisateurs, ou reformulée à l'identique, ne déclenche plus
|
| 85 |
+
d'appel réseau tant que l'entrée est encore dans le cache (TTL configurable).
|
| 86 |
+
"""
|
| 87 |
+
key = make_cache_key("query_embedding", settings.embedding_model, text.strip().lower())
|
| 88 |
+
cached = embedding_cache.get(key)
|
| 89 |
+
if cached is not None:
|
| 90 |
+
return cached
|
| 91 |
+
|
| 92 |
+
vector = embed_texts([text], kind="query")[0]
|
| 93 |
+
embedding_cache.set(key, vector)
|
| 94 |
+
return vector
|
core/retrieval/hybrid_search.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Recherche cross-lingue en 3 modes (voir README / onglet Architecture) :
|
| 3 |
+
|
| 4 |
+
1. Cross-lingue global -> cherche dans toutes les langues.
|
| 5 |
+
2. Filtré par langue -> restreint aux documents d'une langue choisie.
|
| 6 |
+
3. Priorité locale -> léger bonus de score pour la langue de la
|
| 7 |
+
question, sans jamais exclure les autres langues.
|
| 8 |
+
|
| 9 |
+
Étapes : top-15 brut -> bonus langue -> tri -> top-5 final -> score de
|
| 10 |
+
confiance global. Si le score de confiance est trop faible, on répond que
|
| 11 |
+
le corpus ne contient probablement pas l'information (mode strict / Corrective
|
| 12 |
+
RAG minimal), plutôt que d'halluciner.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
from core.ingestion.language_detector import detect_language
|
| 19 |
+
from core.retrieval.embeddings import embed_query
|
| 20 |
+
from core.retrieval.vector_store import SearchHit, VectorStore
|
| 21 |
+
from config import settings
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class RetrievalResult:
|
| 26 |
+
hits: list[SearchHit]
|
| 27 |
+
query_language: str
|
| 28 |
+
query_language_confidence: float
|
| 29 |
+
confidence_score: float
|
| 30 |
+
is_sufficient: bool
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def retrieve(
|
| 34 |
+
question: str,
|
| 35 |
+
store: VectorStore,
|
| 36 |
+
language_filter: str | None = None,
|
| 37 |
+
forced_query_language: str | None = None,
|
| 38 |
+
) -> RetrievalResult:
|
| 39 |
+
query_lang, lang_conf = (
|
| 40 |
+
(forced_query_language, 1.0)
|
| 41 |
+
if forced_query_language
|
| 42 |
+
else detect_language(question)
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
query_vector = embed_query(question)
|
| 46 |
+
raw_hits = store.search(query_vector, top_k=settings.top_k_retrieve)
|
| 47 |
+
|
| 48 |
+
if language_filter:
|
| 49 |
+
raw_hits = [h for h in raw_hits if h.language == language_filter]
|
| 50 |
+
|
| 51 |
+
# Bonus léger pour la langue de la question (priorité locale puis globale).
|
| 52 |
+
def adjusted_score(hit: SearchHit) -> float:
|
| 53 |
+
bonus = settings.same_language_bonus if hit.language == query_lang else 0.0
|
| 54 |
+
return hit.score + bonus
|
| 55 |
+
|
| 56 |
+
ranked = sorted(raw_hits, key=adjusted_score, reverse=True)
|
| 57 |
+
top_hits = ranked[: settings.top_k_final]
|
| 58 |
+
|
| 59 |
+
confidence = top_hits[0].score if top_hits else 0.0
|
| 60 |
+
is_sufficient = confidence >= settings.min_confidence_score and bool(top_hits)
|
| 61 |
+
|
| 62 |
+
return RetrievalResult(
|
| 63 |
+
hits=top_hits,
|
| 64 |
+
query_language=query_lang,
|
| 65 |
+
query_language_confidence=lang_conf,
|
| 66 |
+
confidence_score=confidence,
|
| 67 |
+
is_sufficient=is_sufficient,
|
| 68 |
+
)
|
core/retrieval/vector_store.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Index vectoriel en mémoire (FAISS) construit à partir du Parquet stocké
|
| 3 |
+
dans le dataset privé Hugging Face.
|
| 4 |
+
|
| 5 |
+
Choix d'architecture assumé (documenté dans le README) : plutôt qu'un
|
| 6 |
+
service Qdrant externe séparé — qui demanderait une seconde clé d'API et
|
| 7 |
+
un second service à héberger — le vecteur-store tient dans le Space
|
| 8 |
+
Gradio lui-même : au démarrage, on télécharge le Parquet privé (avec
|
| 9 |
+
HF_TOKEN), on construit un index FAISS en mémoire. Pour un corpus de
|
| 10 |
+
quelques dizaines de milliers de chunks (cas normal d'un MVP ou d'une
|
| 11 |
+
PME), c'est largement suffisant et ça supprime une dépendance opérationnelle
|
| 12 |
+
tout en respectant la contrainte « je n'ai qu'un jeton API Hugging Face ».
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import threading
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pandas as pd
|
| 22 |
+
|
| 23 |
+
from config import settings
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class SearchHit:
|
| 28 |
+
chunk_id: str
|
| 29 |
+
document_id: str
|
| 30 |
+
source_file: str
|
| 31 |
+
page: int
|
| 32 |
+
language: str
|
| 33 |
+
title: str | None
|
| 34 |
+
text_direction: str
|
| 35 |
+
text: str
|
| 36 |
+
score: float
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class VectorStore:
|
| 40 |
+
"""Encapsule le DataFrame de métadonnées + l'index FAISS associé."""
|
| 41 |
+
|
| 42 |
+
def __init__(self) -> None:
|
| 43 |
+
self._lock = threading.Lock()
|
| 44 |
+
self.df: pd.DataFrame | None = None
|
| 45 |
+
self.index = None # faiss.Index
|
| 46 |
+
self.loaded_from: str | None = None
|
| 47 |
+
self.ann_index_type: str = settings.ann_index_type
|
| 48 |
+
|
| 49 |
+
# ------------------------------------------------------------------ #
|
| 50 |
+
# Chargement
|
| 51 |
+
# ------------------------------------------------------------------ #
|
| 52 |
+
def load_from_parquet(self, parquet_path: Path) -> None:
|
| 53 |
+
df = pd.read_parquet(parquet_path)
|
| 54 |
+
self._build_index(df)
|
| 55 |
+
self.loaded_from = str(parquet_path)
|
| 56 |
+
|
| 57 |
+
def load_from_hub(self, repo_id: str, token: str, local_cache: Path) -> None:
|
| 58 |
+
from huggingface_hub import hf_hub_download
|
| 59 |
+
|
| 60 |
+
local_cache.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
parquet_file = hf_hub_download(
|
| 62 |
+
repo_id=repo_id,
|
| 63 |
+
repo_type="dataset",
|
| 64 |
+
filename="chunks.parquet",
|
| 65 |
+
token=token,
|
| 66 |
+
local_dir=str(local_cache),
|
| 67 |
+
)
|
| 68 |
+
self.load_from_parquet(Path(parquet_file))
|
| 69 |
+
self.loaded_from = f"hf://datasets/{repo_id}"
|
| 70 |
+
|
| 71 |
+
def _build_index(self, df: pd.DataFrame) -> None:
|
| 72 |
+
import faiss
|
| 73 |
+
|
| 74 |
+
with self._lock:
|
| 75 |
+
self.df = df.reset_index(drop=True)
|
| 76 |
+
if len(self.df) == 0:
|
| 77 |
+
self.index = None
|
| 78 |
+
return
|
| 79 |
+
vectors = np.vstack(
|
| 80 |
+
self.df["embedding"].apply(lambda v: np.array(v, dtype="float32"))
|
| 81 |
+
)
|
| 82 |
+
dim = vectors.shape[1]
|
| 83 |
+
|
| 84 |
+
if settings.ann_index_type == "hnsw":
|
| 85 |
+
# Recherche APPROXIMATIVE (ANN) : FAISS HNSW. Pas d'étape
|
| 86 |
+
# d'entraînement requise (contrairement à IVF), donc valable
|
| 87 |
+
# même sur un tout petit corpus de démo. `efSearch` contrôle
|
| 88 |
+
# le compromis vitesse/rappel au moment de la requête.
|
| 89 |
+
index = faiss.IndexHNSWFlat(dim, settings.ann_hnsw_m, faiss.METRIC_INNER_PRODUCT)
|
| 90 |
+
index.hnsw.efConstruction = max(40, settings.ann_hnsw_m * 2)
|
| 91 |
+
index.add(vectors)
|
| 92 |
+
index.hnsw.efSearch = settings.ann_ef_search
|
| 93 |
+
else:
|
| 94 |
+
# Recherche EXACTE (produit scalaire = cosinus, vecteurs
|
| 95 |
+
# normalisés) : plus lente sur un grand corpus, utile en
|
| 96 |
+
# comparaison/qualité ou sur un petit corpus.
|
| 97 |
+
index = faiss.IndexFlatIP(dim)
|
| 98 |
+
index.add(vectors)
|
| 99 |
+
|
| 100 |
+
self.index = index
|
| 101 |
+
self.ann_index_type = settings.ann_index_type
|
| 102 |
+
|
| 103 |
+
# ------------------------------------------------------------------ #
|
| 104 |
+
# Recherche
|
| 105 |
+
# ------------------------------------------------------------------ #
|
| 106 |
+
def is_ready(self) -> bool:
|
| 107 |
+
return self.index is not None and self.df is not None and len(self.df) > 0
|
| 108 |
+
|
| 109 |
+
def search(self, query_vector: np.ndarray, top_k: int = 15) -> list[SearchHit]:
|
| 110 |
+
if not self.is_ready():
|
| 111 |
+
return []
|
| 112 |
+
query_vector = query_vector.reshape(1, -1).astype("float32")
|
| 113 |
+
scores, indices = self.index.search(query_vector, min(top_k, len(self.df)))
|
| 114 |
+
hits: list[SearchHit] = []
|
| 115 |
+
for score, idx in zip(scores[0], indices[0]):
|
| 116 |
+
if idx < 0:
|
| 117 |
+
continue
|
| 118 |
+
row = self.df.iloc[idx]
|
| 119 |
+
hits.append(
|
| 120 |
+
SearchHit(
|
| 121 |
+
chunk_id=row["chunk_id"],
|
| 122 |
+
document_id=row["document_id"],
|
| 123 |
+
source_file=row["source_file"],
|
| 124 |
+
page=int(row["page"]),
|
| 125 |
+
language=row["language"],
|
| 126 |
+
title=row.get("title"),
|
| 127 |
+
text_direction=row.get("text_direction", "ltr"),
|
| 128 |
+
text=row["text"],
|
| 129 |
+
score=float(score),
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
return hits
|
| 133 |
+
|
| 134 |
+
def corpus_stats(self) -> dict:
|
| 135 |
+
if not self.is_ready():
|
| 136 |
+
return {"total_chunks": 0, "by_language": {}, "documents": 0, "ann_index_type": self.ann_index_type}
|
| 137 |
+
return {
|
| 138 |
+
"total_chunks": int(len(self.df)),
|
| 139 |
+
"by_language": self.df["language"].value_counts().to_dict(),
|
| 140 |
+
"documents": int(self.df["document_id"].nunique()),
|
| 141 |
+
"ann_index_type": self.ann_index_type,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
_store_singleton: VectorStore | None = None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_vector_store() -> VectorStore:
|
| 149 |
+
"""Charge (une seule fois) l'index, depuis le dataset HF si configuré,
|
| 150 |
+
sinon depuis le Parquet local (utile en développement)."""
|
| 151 |
+
global _store_singleton
|
| 152 |
+
if _store_singleton is not None:
|
| 153 |
+
return _store_singleton
|
| 154 |
+
|
| 155 |
+
store = VectorStore()
|
| 156 |
+
local_parquet = Path(settings.local_index_dir) / "chunks.parquet"
|
| 157 |
+
|
| 158 |
+
if settings.hf_dataset_repo and settings.hf_token:
|
| 159 |
+
try:
|
| 160 |
+
store.load_from_hub(
|
| 161 |
+
settings.hf_dataset_repo,
|
| 162 |
+
settings.hf_token,
|
| 163 |
+
Path(settings.local_index_dir) / "hub_cache",
|
| 164 |
+
)
|
| 165 |
+
except Exception as exc: # noqa: BLE001
|
| 166 |
+
print(f"[vector_store] Impossible de charger le dataset HF ({exc}). "
|
| 167 |
+
f"Repli sur l'index local si présent.")
|
| 168 |
+
if local_parquet.exists():
|
| 169 |
+
store.load_from_parquet(local_parquet)
|
| 170 |
+
elif local_parquet.exists():
|
| 171 |
+
store.load_from_parquet(local_parquet)
|
| 172 |
+
else:
|
| 173 |
+
print(
|
| 174 |
+
"[vector_store] Aucun index trouvé (ni dataset HF, ni Parquet local). "
|
| 175 |
+
"Lancez `python scripts/ingest.py` d'abord."
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
_store_singleton = store
|
| 179 |
+
return store
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def reset_vector_store_cache() -> None:
|
| 183 |
+
global _store_singleton
|
| 184 |
+
_store_singleton = None
|
core/ui/__init__.py
ADDED
|
File without changes
|
core/ui/admin_tab.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onglet Admin : métriques du RAG (volume, latence, langues, abstention)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from core.metrics.logger import fetch_recent, summary_metrics
|
| 8 |
+
from core.optimization.cache import answer_cache, embedding_cache
|
| 9 |
+
from core.retrieval.vector_store import get_vector_store
|
| 10 |
+
from config import settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _kpi_html(label: str, value: str) -> str:
|
| 14 |
+
return (
|
| 15 |
+
f'<div class="pg-kpi-card"><div class="pg-kpi-value">{value}</div>'
|
| 16 |
+
f'<div class="pg-kpi-label">{label}</div></div>'
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _check_password(password: str) -> bool:
|
| 21 |
+
if not settings.admin_password:
|
| 22 |
+
return True # aucun mot de passe configuré -> accès libre (démo)
|
| 23 |
+
return password == settings.admin_password
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def refresh_dashboard(password: str):
|
| 27 |
+
if not _check_password(password):
|
| 28 |
+
denied = "🔒 Mot de passe administrateur incorrect."
|
| 29 |
+
return (
|
| 30 |
+
gr.update(visible=False),
|
| 31 |
+
denied,
|
| 32 |
+
None,
|
| 33 |
+
None,
|
| 34 |
+
None,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
metrics = summary_metrics()
|
| 38 |
+
store = get_vector_store()
|
| 39 |
+
corpus = store.corpus_stats()
|
| 40 |
+
|
| 41 |
+
answer_cache_stats = answer_cache.stats()
|
| 42 |
+
embedding_cache_stats = embedding_cache.stats()
|
| 43 |
+
|
| 44 |
+
kpis = "".join(
|
| 45 |
+
[
|
| 46 |
+
_kpi_html("Requêtes traitées", str(metrics["total_queries"])),
|
| 47 |
+
_kpi_html("Taux d'abstention", f"{metrics['abstention_rate']:.0%}"),
|
| 48 |
+
_kpi_html("Confiance moyenne", f"{metrics['avg_confidence']:.2f}"),
|
| 49 |
+
_kpi_html("Latence moyenne", f"{metrics['avg_latency_ms']:.0f} ms"),
|
| 50 |
+
_kpi_html("Chunks indexés", str(corpus["total_chunks"])),
|
| 51 |
+
_kpi_html("Documents indexés", str(corpus["documents"])),
|
| 52 |
+
_kpi_html("Index vectoriel", corpus.get("ann_index_type", "—").upper()),
|
| 53 |
+
_kpi_html(
|
| 54 |
+
"Cache réponses",
|
| 55 |
+
f"{answer_cache_stats['size']}/{answer_cache_stats['maxsize']}"
|
| 56 |
+
if answer_cache_stats["enabled"] else "désactivé",
|
| 57 |
+
),
|
| 58 |
+
_kpi_html(
|
| 59 |
+
"Cache embeddings",
|
| 60 |
+
f"{embedding_cache_stats['size']}/{embedding_cache_stats['maxsize']}"
|
| 61 |
+
if embedding_cache_stats["enabled"] else "désactivé",
|
| 62 |
+
),
|
| 63 |
+
]
|
| 64 |
+
)
|
| 65 |
+
kpi_html = f'<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">{kpis}</div>'
|
| 66 |
+
|
| 67 |
+
lang_query_df = pd.DataFrame(
|
| 68 |
+
{
|
| 69 |
+
"langue": list(metrics["by_query_language"].keys()) or ["—"],
|
| 70 |
+
"requêtes": list(metrics["by_query_language"].values()) or [0],
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
corpus_lang_df = pd.DataFrame(
|
| 74 |
+
{
|
| 75 |
+
"langue": list(corpus["by_language"].keys()) or ["—"],
|
| 76 |
+
"chunks": list(corpus["by_language"].values()) or [0],
|
| 77 |
+
}
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
recent = fetch_recent(limit=25)
|
| 81 |
+
recent_df = pd.DataFrame(
|
| 82 |
+
[
|
| 83 |
+
{
|
| 84 |
+
"question": r["question"][:90],
|
| 85 |
+
"langue question": r["query_language"],
|
| 86 |
+
"langue réponse": r["target_language"],
|
| 87 |
+
"confiance": round(r["confidence_score"], 2),
|
| 88 |
+
"abstention": bool(r["used_fallback"]),
|
| 89 |
+
"latence (ms)": round(r["total_latency_ms"], 0),
|
| 90 |
+
}
|
| 91 |
+
for r in recent
|
| 92 |
+
]
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return gr.update(visible=True), kpi_html, lang_query_df, corpus_lang_df, recent_df
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def build_admin_tab():
|
| 99 |
+
with gr.Column():
|
| 100 |
+
gr.Markdown(
|
| 101 |
+
"### 🔐 Tableau de bord administrateur — métriques RAG\n"
|
| 102 |
+
"Définissez `ADMIN_PASSWORD` (variable d'environnement) pour "
|
| 103 |
+
"protéger cet onglet en production."
|
| 104 |
+
)
|
| 105 |
+
with gr.Row():
|
| 106 |
+
password_box = gr.Textbox(
|
| 107 |
+
label="Mot de passe administrateur",
|
| 108 |
+
type="password",
|
| 109 |
+
scale=3,
|
| 110 |
+
)
|
| 111 |
+
refresh_btn = gr.Button("Actualiser le tableau de bord", variant="primary", scale=1)
|
| 112 |
+
|
| 113 |
+
dashboard_group = gr.Group(visible=False)
|
| 114 |
+
kpi_html_box = gr.HTML()
|
| 115 |
+
|
| 116 |
+
with dashboard_group:
|
| 117 |
+
with gr.Row():
|
| 118 |
+
with gr.Column():
|
| 119 |
+
gr.Markdown("**Volume de requêtes par langue posée**")
|
| 120 |
+
lang_query_plot = gr.BarPlot(
|
| 121 |
+
x="langue", y="requêtes", title="", height=280
|
| 122 |
+
)
|
| 123 |
+
with gr.Column():
|
| 124 |
+
gr.Markdown("**Répartition du corpus indexé par langue**")
|
| 125 |
+
corpus_lang_plot = gr.BarPlot(
|
| 126 |
+
x="langue", y="chunks", title="", height=280
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
gr.Markdown("**Historique des 25 dernières requêtes**")
|
| 130 |
+
recent_table = gr.Dataframe(wrap=True)
|
| 131 |
+
|
| 132 |
+
refresh_btn.click(
|
| 133 |
+
refresh_dashboard,
|
| 134 |
+
inputs=[password_box],
|
| 135 |
+
outputs=[dashboard_group, kpi_html_box, lang_query_plot, corpus_lang_plot, recent_table],
|
| 136 |
+
)
|
core/ui/architecture_tab.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onglet Architecture : schéma du pipeline + explications synthétiques."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
_SVG_PATH = Path(__file__).resolve().parent.parent / "assets" / "architecture.svg"
|
| 9 |
+
|
| 10 |
+
_EXPLANATION_MD = """
|
| 11 |
+
### Pourquoi cette architecture ?
|
| 12 |
+
|
| 13 |
+
**On ne traduit jamais tout le corpus.** Chaque document conserve son texte
|
| 14 |
+
et sa langue d'origine ; seuls des **embeddings multilingues partagés**
|
| 15 |
+
(`BAAI/bge-m3`) projettent toutes les langues dans le même espace
|
| 16 |
+
sémantique, ce qui permet une recherche véritablement **cross-lingue** :
|
| 17 |
+
une question posée en russe retrouve un règlement rédigé en français ou en
|
| 18 |
+
anglais, sans étape de traduction préalable et sans perte de nuance
|
| 19 |
+
juridique ou métier.
|
| 20 |
+
|
| 21 |
+
**Un seul jeton d'API.** Génération (`DeepSeek-V4.1-Flash`) et embeddings
|
| 22 |
+
passent tous les deux par le routeur **Inference Providers** de Hugging
|
| 23 |
+
Face — un seul `HF_TOKEN` suffit pour l'ensemble de la chaîne, y compris la
|
| 24 |
+
lecture/écriture du dataset privé.
|
| 25 |
+
|
| 26 |
+
**Le vecteur-store tient dans le Space.** Plutôt qu'un service Qdrant
|
| 27 |
+
externe à héberger séparément, l'index est un fichier Parquet stocké dans
|
| 28 |
+
un **dataset privé Hugging Face** et chargé en mémoire (FAISS) au démarrage
|
| 29 |
+
du Space — zéro dépendance opérationnelle supplémentaire.
|
| 30 |
+
|
| 31 |
+
**Mode strict.** Si le meilleur score de similarité retourné par la
|
| 32 |
+
recherche est inférieur au seuil de confiance configuré, l'assistant
|
| 33 |
+
s'abstient explicitement plutôt que d'halluciner une réponse.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_architecture_tab():
|
| 38 |
+
with gr.Column():
|
| 39 |
+
gr.Markdown("### 🏗️ Architecture de PolyglotRAG")
|
| 40 |
+
svg_content = _SVG_PATH.read_text(encoding="utf-8")
|
| 41 |
+
gr.HTML(f'<div style="background:white;border-radius:14px;padding:8px;">{svg_content}</div>')
|
| 42 |
+
gr.Markdown(_EXPLANATION_MD)
|
core/ui/chat_tab.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onglet Chat : question multilingue, réponse ciblée, citations, mode RTL."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
|
| 6 |
+
from core.ingestion.language_detector import language_label
|
| 7 |
+
from core.pipeline import ask
|
| 8 |
+
from core.retrieval.vector_store import get_vector_store
|
| 9 |
+
from config import SUPPORTED_LANGUAGES
|
| 10 |
+
|
| 11 |
+
LANGUAGE_CHOICES = [("Automatique (détection)", "auto")] + [
|
| 12 |
+
(info["name"], code) for code, info in SUPPORTED_LANGUAGES.items()
|
| 13 |
+
]
|
| 14 |
+
LANGUAGE_FILTER_CHOICES = [("Toutes les langues", "all")] + [
|
| 15 |
+
(info["name"], code) for code, info in SUPPORTED_LANGUAGES.items()
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
EXAMPLE_QUESTIONS = [
|
| 19 |
+
"Какой срок возврата средств?",
|
| 20 |
+
"Quels sont les délais de remboursement ?",
|
| 21 |
+
"What is the refund deadline?",
|
| 22 |
+
"¿Cuál es el plazo de reembolso?",
|
| 23 |
+
"ما هي مدة استرداد الأموال؟",
|
| 24 |
+
"Qual é o prazo para o reembolso?",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _format_sources_html(sources: list[dict]) -> str:
|
| 29 |
+
if not sources:
|
| 30 |
+
return "<em>Aucune source utilisée (réponse d'abstention).</em>"
|
| 31 |
+
chips = []
|
| 32 |
+
for s in sources:
|
| 33 |
+
label = language_label(s["language"])
|
| 34 |
+
chips.append(
|
| 35 |
+
f'<span class="pg-source-chip">📄 {s["file"]} · {label} · '
|
| 36 |
+
f'p.{s["page"]} · score {s["score"]}</span>'
|
| 37 |
+
)
|
| 38 |
+
return "".join(chips)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def answer_question(
|
| 42 |
+
question: str,
|
| 43 |
+
response_language_choice: str,
|
| 44 |
+
filter_language_choice: str,
|
| 45 |
+
):
|
| 46 |
+
if not question or not question.strip():
|
| 47 |
+
return "", "", gr.update(visible=False)
|
| 48 |
+
|
| 49 |
+
store = get_vector_store()
|
| 50 |
+
if not store.is_ready():
|
| 51 |
+
msg = (
|
| 52 |
+
"⚠️ L'index documentaire est vide. Lancez d'abord l'ingestion "
|
| 53 |
+
"(`python scripts/ingest.py`) ou configurez HF_DATASET_REPO."
|
| 54 |
+
)
|
| 55 |
+
return msg, "", gr.update(visible=False)
|
| 56 |
+
|
| 57 |
+
lang_filter = None if filter_language_choice == "all" else filter_language_choice
|
| 58 |
+
outcome = ask(question, store, response_language_choice, lang_filter)
|
| 59 |
+
retrieval, result = outcome.retrieval, outcome.generation
|
| 60 |
+
|
| 61 |
+
is_rtl = SUPPORTED_LANGUAGES.get(outcome.target_language, {}).get("dir") == "rtl"
|
| 62 |
+
answer_html = (
|
| 63 |
+
f'<div class="{"pg-rtl-answer" if is_rtl else ""}">{result.answer}</div>'
|
| 64 |
+
)
|
| 65 |
+
cache_note = " · ⚡ **servi depuis le cache**" if outcome.from_cache else ""
|
| 66 |
+
meta = (
|
| 67 |
+
f"Langue détectée de la question : **{language_label(retrieval.query_language)}** "
|
| 68 |
+
f"(confiance {retrieval.query_language_confidence:.0%}) · "
|
| 69 |
+
f"Score de confiance retrieval : **{retrieval.confidence_score:.2f}** · "
|
| 70 |
+
f"Latence totale : **{outcome.retrieval_latency_ms + result.latency_seconds * 1000:.0f} ms**"
|
| 71 |
+
f"{cache_note}"
|
| 72 |
+
)
|
| 73 |
+
sources_html = _format_sources_html(result.sources)
|
| 74 |
+
|
| 75 |
+
return answer_html, meta, gr.update(value=sources_html, visible=True)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_chat_tab():
|
| 79 |
+
with gr.Column():
|
| 80 |
+
gr.Markdown(
|
| 81 |
+
"### 💬 Poser une question — dans n'importe laquelle des 6 langues"
|
| 82 |
+
)
|
| 83 |
+
with gr.Row():
|
| 84 |
+
question_box = gr.Textbox(
|
| 85 |
+
label="Votre question",
|
| 86 |
+
placeholder="Posez votre question en EN / FR / RU / ES / AR / PT…",
|
| 87 |
+
lines=2,
|
| 88 |
+
scale=4,
|
| 89 |
+
)
|
| 90 |
+
with gr.Row():
|
| 91 |
+
response_lang = gr.Dropdown(
|
| 92 |
+
choices=LANGUAGE_CHOICES,
|
| 93 |
+
value="auto",
|
| 94 |
+
label="Répondre dans la langue du document / Traduire la réponse",
|
| 95 |
+
info="« Automatique » répond dans la langue de la question.",
|
| 96 |
+
scale=2,
|
| 97 |
+
)
|
| 98 |
+
filter_lang = gr.Dropdown(
|
| 99 |
+
choices=LANGUAGE_FILTER_CHOICES,
|
| 100 |
+
value="all",
|
| 101 |
+
label="Restreindre la recherche à une langue",
|
| 102 |
+
scale=2,
|
| 103 |
+
)
|
| 104 |
+
submit_btn = gr.Button("Rechercher & répondre", variant="primary", scale=1)
|
| 105 |
+
|
| 106 |
+
gr.Examples(examples=EXAMPLE_QUESTIONS, inputs=question_box)
|
| 107 |
+
|
| 108 |
+
answer_box = gr.HTML(label="Réponse")
|
| 109 |
+
meta_box = gr.Markdown()
|
| 110 |
+
sources_box = gr.HTML(visible=False, label="Sources")
|
| 111 |
+
|
| 112 |
+
submit_btn.click(
|
| 113 |
+
answer_question,
|
| 114 |
+
inputs=[question_box, response_lang, filter_lang],
|
| 115 |
+
outputs=[answer_box, meta_box, sources_box],
|
| 116 |
+
)
|
| 117 |
+
question_box.submit(
|
| 118 |
+
answer_question,
|
| 119 |
+
inputs=[question_box, response_lang, filter_lang],
|
| 120 |
+
outputs=[answer_box, meta_box, sources_box],
|
| 121 |
+
)
|
core/ui/theme.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSS partagé — identité visuelle sobre et professionnelle (bleu nuit / or)."""
|
| 2 |
+
|
| 3 |
+
CUSTOM_CSS = """
|
| 4 |
+
:root {
|
| 5 |
+
--pg-navy: #0f2942;
|
| 6 |
+
--pg-navy-2: #1d3557;
|
| 7 |
+
--pg-gold: #b8860b;
|
| 8 |
+
--pg-teal: #0ea5a3;
|
| 9 |
+
}
|
| 10 |
+
.gradio-container {
|
| 11 |
+
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif !important;
|
| 12 |
+
max-width: 1280px !important;
|
| 13 |
+
}
|
| 14 |
+
#pg-header {
|
| 15 |
+
background: linear-gradient(90deg, var(--pg-navy) 0%, var(--pg-navy-2) 100%);
|
| 16 |
+
color: white;
|
| 17 |
+
padding: 22px 28px;
|
| 18 |
+
border-radius: 14px;
|
| 19 |
+
margin-bottom: 14px;
|
| 20 |
+
}
|
| 21 |
+
#pg-header h1 { margin: 0; font-size: 1.55rem; font-weight: 700; }
|
| 22 |
+
#pg-header p { margin: 6px 0 0 0; color: #c9d6e3; font-size: 0.92rem; }
|
| 23 |
+
.pg-kpi-card {
|
| 24 |
+
border: 1px solid #e2e8f0;
|
| 25 |
+
border-radius: 12px;
|
| 26 |
+
padding: 16px 18px;
|
| 27 |
+
background: white;
|
| 28 |
+
box-shadow: 0 1px 3px rgba(15,41,66,0.06);
|
| 29 |
+
}
|
| 30 |
+
.pg-kpi-value { font-size: 1.7rem; font-weight: 800; color: var(--pg-navy); }
|
| 31 |
+
.pg-kpi-label { font-size: 0.82rem; color: #64748b; text-transform: uppercase; letter-spacing: .04em; }
|
| 32 |
+
.pg-rtl-answer { direction: rtl; text-align: right; font-size: 1.05rem; }
|
| 33 |
+
.pg-source-chip {
|
| 34 |
+
display: inline-block; background: #eef4fb; color: #0f2942;
|
| 35 |
+
border: 1px solid #cfe0f3; border-radius: 999px; padding: 3px 10px;
|
| 36 |
+
font-size: 0.78rem; margin: 2px 4px 2px 0;
|
| 37 |
+
}
|
| 38 |
+
footer { visibility: hidden; }
|
| 39 |
+
"""
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
polyglot-rag:
|
| 3 |
+
build: .
|
| 4 |
+
image: polyglot-rag:latest
|
| 5 |
+
container_name: polyglot-rag
|
| 6 |
+
ports:
|
| 7 |
+
- "7860:7860"
|
| 8 |
+
env_file:
|
| 9 |
+
- .env
|
| 10 |
+
volumes:
|
| 11 |
+
# Persiste l'index local et la base de métriques entre deux redémarrages
|
| 12 |
+
# du conteneur (facultatif si vous chargez toujours l'index depuis le
|
| 13 |
+
# dataset privé Hugging Face via HF_DATASET_REPO).
|
| 14 |
+
- ./data:/app/data
|
| 15 |
+
restart: unless-stopped
|
knowledge_base/ar/سياسة_الاسترجاع_ar.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# سياسة استرداد الأموال
|
| 2 |
+
|
| 3 |
+
## 1. مهلة الاسترداد
|
| 4 |
+
|
| 5 |
+
يتم رد المبلغ المدفوع في غضون **15 يوم عمل** كحد أقصى بعد موافقة خدمة
|
| 6 |
+
العملاء على الطلب.
|
| 7 |
+
|
| 8 |
+
## 2. شروط الأهلية
|
| 9 |
+
|
| 10 |
+
يكون المنتج مؤهلاً للاسترداد إذا:
|
| 11 |
+
|
| 12 |
+
- تمت إعادته في عبوته الأصلية؛
|
| 13 |
+
- لم يُستخدم بما يتجاوز الاستخدام التجريبي المعقول؛
|
| 14 |
+
- قُدّم الطلب خلال 30 يومًا من تاريخ التسليم؛
|
| 15 |
+
- تم إرفاق إثبات الشراء (فاتورة أو رقم الطلب).
|
| 16 |
+
|
| 17 |
+
## 3. طريقة الاسترداد
|
| 18 |
+
|
| 19 |
+
يتم رد المبلغ بنفس وسيلة الدفع الأصلية. في حال تعذّر ذلك (بطاقة منتهية
|
| 20 |
+
الصلاحية أو حساب مغلق)، يُقترح على العميل رصيد بديل أو تحويل بنكي.
|
| 21 |
+
|
| 22 |
+
## 4. حالات خاصة
|
| 23 |
+
|
| 24 |
+
المنتجات المخصصة وتراخيص البرامج المُفعّلة غير قابلة للاسترداد، إلا في
|
| 25 |
+
حال ثبوت عيب تصنيع من قبل فريق الجودة.
|
| 26 |
+
|
| 27 |
+
## 5. النزاعات
|
| 28 |
+
|
| 29 |
+
في حال الاعتراض على قرار الاسترداد، يمكن للعميل تصعيد الطلب إلى خدمة
|
| 30 |
+
"العملاء - المستوى الثاني"، الملزمة بالرد خلال 5 أيام عمل.
|
knowledge_base/en/employee_handbook_en.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Employee Handbook — Refunds & Expense Policy
|
| 2 |
+
|
| 3 |
+
## 1. Customer refund requests
|
| 4 |
+
|
| 5 |
+
Refund requests must be submitted within **30 calendar days** of purchase.
|
| 6 |
+
This 30-day window applies to the submission of the request, not
|
| 7 |
+
necessarily to the timeline of the actual money transfer, which follows the
|
| 8 |
+
standard processing time defined by the finance department (typically
|
| 9 |
+
5-10 business days once the request is approved).
|
| 10 |
+
|
| 11 |
+
## 2. Approval workflow
|
| 12 |
+
|
| 13 |
+
1. The customer support agent verifies proof of purchase.
|
| 14 |
+
2. The request is logged in the CRM with a unique ticket ID.
|
| 15 |
+
3. A supervisor approves refunds above $500.
|
| 16 |
+
4. Finance processes the transfer or store credit.
|
| 17 |
+
|
| 18 |
+
## 3. Employee expense reimbursement
|
| 19 |
+
|
| 20 |
+
Employees who incur business expenses (travel, client meals, software
|
| 21 |
+
subscriptions) must submit receipts within 15 days. Reimbursement is paid
|
| 22 |
+
with the next payroll cycle after approval.
|
| 23 |
+
|
| 24 |
+
## 4. Exceptions
|
| 25 |
+
|
| 26 |
+
Digital goods that have been downloaded or activated are non-refundable,
|
| 27 |
+
except in case of a proven technical defect confirmed by the engineering
|
| 28 |
+
team.
|
| 29 |
+
|
| 30 |
+
## 5. Escalation
|
| 31 |
+
|
| 32 |
+
If a refund is contested, employees should escalate to the Tier-2 Customer
|
| 33 |
+
Relations team, which commits to a response within 5 business days.
|
knowledge_base/es/politica_devoluciones_es.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Política de reembolso
|
| 2 |
+
|
| 3 |
+
## 1. Plazo de reembolso
|
| 4 |
+
|
| 5 |
+
El reembolso se realiza en un plazo máximo de **20 días naturales** tras la
|
| 6 |
+
aprobación de la solicitud por parte del servicio de atención al cliente.
|
| 7 |
+
|
| 8 |
+
## 2. Condiciones de elegibilidad
|
| 9 |
+
|
| 10 |
+
Un producto es elegible para reembolso si:
|
| 11 |
+
|
| 12 |
+
- se devuelve en su embalaje original;
|
| 13 |
+
- no presenta signos de uso más allá de una prueba razonable;
|
| 14 |
+
- la solicitud se realiza dentro de los 30 días posteriores a la entrega;
|
| 15 |
+
- se adjunta un comprobante de compra.
|
| 16 |
+
|
| 17 |
+
## 3. Método de reembolso
|
| 18 |
+
|
| 19 |
+
El reembolso se efectúa mediante el mismo método de pago utilizado en la
|
| 20 |
+
compra original. Si no está disponible, se ofrece un vale o transferencia
|
| 21 |
+
bancaria.
|
| 22 |
+
|
| 23 |
+
## 4. Excepciones
|
| 24 |
+
|
| 25 |
+
Los productos personalizados y las licencias de software activadas no son
|
| 26 |
+
reembolsables, salvo defecto de fabricación confirmado por el equipo de
|
| 27 |
+
calidad.
|
| 28 |
+
|
| 29 |
+
## 5. Disputas
|
| 30 |
+
|
| 31 |
+
En caso de desacuerdo, el cliente puede escalar la solicitud al servicio de
|
| 32 |
+
"Atención al Cliente Nivel 2", que debe responder en un plazo de 5 días
|
| 33 |
+
hábiles.
|
knowledge_base/fr/politique_remboursement_fr.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Politique de remboursement
|
| 2 |
+
|
| 3 |
+
## 1. Délai de remboursement
|
| 4 |
+
|
| 5 |
+
Le remboursement est effectué dans un délai maximal de **14 jours** après
|
| 6 |
+
l'approbation de la demande par le service client. Ce délai court à partir
|
| 7 |
+
de la date de réception du produit retourné par notre entrepôt, et non à
|
| 8 |
+
partir de la date d'envoi par le client.
|
| 9 |
+
|
| 10 |
+
## 2. Conditions d'éligibilité
|
| 11 |
+
|
| 12 |
+
Un produit est éligible au remboursement si :
|
| 13 |
+
|
| 14 |
+
- il est retourné dans son emballage d'origine ;
|
| 15 |
+
- il n'a pas été utilisé au-delà d'un usage d'essai raisonnable ;
|
| 16 |
+
- la demande est faite dans les 30 jours suivant la livraison ;
|
| 17 |
+
- une preuve d'achat est fournie (facture ou numéro de commande).
|
| 18 |
+
|
| 19 |
+
## 3. Modes de remboursement
|
| 20 |
+
|
| 21 |
+
Le remboursement est effectué selon le mode de paiement initial. Si le
|
| 22 |
+
paiement initial n'est plus disponible (carte expirée, compte clos), un
|
| 23 |
+
avoir ou un virement bancaire est proposé.
|
| 24 |
+
|
| 25 |
+
## 4. Cas particuliers
|
| 26 |
+
|
| 27 |
+
Les produits personnalisés, les logiciels dont la licence a été activée et
|
| 28 |
+
les biens périssables ne sont pas remboursables, sauf défaut de fabrication
|
| 29 |
+
constaté par notre service qualité.
|
| 30 |
+
|
| 31 |
+
## 5. Litiges
|
| 32 |
+
|
| 33 |
+
En cas de désaccord sur un remboursement, le client peut escalader la
|
| 34 |
+
demande auprès du service « Relation Client Niveau 2 », qui dispose de
|
| 35 |
+
5 jours ouvrés pour répondre.
|
knowledge_base/pt/politica_de_reembolso_pt.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Política de reembolso
|
| 2 |
+
|
| 3 |
+
## 1. Prazo de reembolso
|
| 4 |
+
|
| 5 |
+
O reembolso é efetuado no prazo máximo de **21 dias corridos** após a
|
| 6 |
+
aprovação do pedido pelo serviço de apoio ao cliente.
|
| 7 |
+
|
| 8 |
+
## 2. Condições de elegibilidade
|
| 9 |
+
|
| 10 |
+
Um produto é elegível para reembolso se:
|
| 11 |
+
|
| 12 |
+
- for devolvido na embalagem original;
|
| 13 |
+
- não apresentar sinais de uso além de um teste razoável;
|
| 14 |
+
- o pedido for feito dentro de 30 dias após a entrega;
|
| 15 |
+
- for apresentado um comprovativo de compra.
|
| 16 |
+
|
| 17 |
+
## 3. Forma de reembolso
|
| 18 |
+
|
| 19 |
+
O reembolso é realizado através do mesmo método de pagamento utilizado na
|
| 20 |
+
compra original. Caso não seja possível, é oferecido um crédito na loja ou
|
| 21 |
+
transferência bancária.
|
| 22 |
+
|
| 23 |
+
## 4. Exceções
|
| 24 |
+
|
| 25 |
+
Produtos personalizados e licenças de software ativadas não são
|
| 26 |
+
reembolsáveis, exceto em caso de defeito de fabrico comprovado pela equipa
|
| 27 |
+
de qualidade.
|
| 28 |
+
|
| 29 |
+
## 5. Litígios
|
| 30 |
+
|
| 31 |
+
Em caso de desacordo, o cliente pode escalar o pedido para o serviço de
|
| 32 |
+
"Apoio ao Cliente Nível 2", que tem um prazo de 5 dias úteis para responder.
|
knowledge_base/ru/faq_ru.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Часто задаваемые вопросы — Возврат средств
|
| 2 |
+
|
| 3 |
+
## 1. Срок рассмотрения заявки
|
| 4 |
+
|
| 5 |
+
Заявка на возврат средств рассматривается в течение **10 рабочих дней**
|
| 6 |
+
с момента получения возвращённого товара на склад. Решение направляется
|
| 7 |
+
клиенту по электронной почте.
|
| 8 |
+
|
| 9 |
+
## 2. Условия возврата
|
| 10 |
+
|
| 11 |
+
Товар принимается к возврату, если:
|
| 12 |
+
|
| 13 |
+
- сохранена оригинальная упаковка;
|
| 14 |
+
- товар не имеет следов эксплуатации;
|
| 15 |
+
- заявка подана в течение 30 дней с момента покупки;
|
| 16 |
+
- предоставлен документ, подтверждающий покупку.
|
| 17 |
+
|
| 18 |
+
## 3. Способ возврата денежных средств
|
| 19 |
+
|
| 20 |
+
Деньги возвращаются тем же способом, которым была произведена оплата.
|
| 21 |
+
Если это невозможно (карта заблокирована, счёт закрыт), клиенту
|
| 22 |
+
предлагается возврат на банковские реквизиты.
|
| 23 |
+
|
| 24 |
+
## 4. Исключения
|
| 25 |
+
|
| 26 |
+
Персонализированные товары и активированные цифровые лицензии возврату
|
| 27 |
+
не подлежат, за исключением случаев подтверждённого производственного
|
| 28 |
+
брака.
|
| 29 |
+
|
| 30 |
+
## 5. Эскалация спора
|
| 31 |
+
|
| 32 |
+
При несогласии с решением клиент может обратиться в службу поддержки
|
| 33 |
+
второго уровня, которая обязана ответить в течение 5 рабочих дней.
|
pytest.ini
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
addopts = -q
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44,<6
|
| 2 |
+
huggingface_hub>=0.25
|
| 3 |
+
python-dotenv>=1.0
|
| 4 |
+
numpy>=1.26
|
| 5 |
+
pandas>=2.2
|
| 6 |
+
pyarrow>=16.0
|
| 7 |
+
faiss-cpu>=1.8
|
| 8 |
+
lingua-language-detector>=2.0
|
| 9 |
+
pypdf>=4.2
|
| 10 |
+
python-docx>=1.1
|
| 11 |
+
beautifulsoup4>=4.12
|
| 12 |
+
lxml>=5.2
|
| 13 |
+
cachetools>=5.3
|
scripts/ingest.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
CLI d'ingestion : construit l'index (Parquet) à partir de `knowledge_base/`
|
| 4 |
+
et, si `HF_DATASET_REPO` est configuré, le pousse vers le dataset privé
|
| 5 |
+
Hugging Face.
|
| 6 |
+
|
| 7 |
+
Usage :
|
| 8 |
+
python scripts/ingest.py
|
| 9 |
+
python scripts/ingest.py --kb-dir chemin/vers/mes_documents --no-push
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
from core.ingestion.indexer import ( # noqa: E402
|
| 20 |
+
build_chunk_records,
|
| 21 |
+
push_index_to_hub,
|
| 22 |
+
save_index_locally,
|
| 23 |
+
)
|
| 24 |
+
from config import settings # noqa: E402
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main() -> None:
|
| 28 |
+
parser = argparse.ArgumentParser(description="Ingestion PolyglotRAG")
|
| 29 |
+
parser.add_argument("--kb-dir", default="knowledge_base", help="Dossier source")
|
| 30 |
+
parser.add_argument(
|
| 31 |
+
"--out-dir", default=settings.local_index_dir, help="Dossier de sortie local"
|
| 32 |
+
)
|
| 33 |
+
parser.add_argument(
|
| 34 |
+
"--no-push", action="store_true", help="Ne pas pousser vers le Hub HF"
|
| 35 |
+
)
|
| 36 |
+
args = parser.parse_args()
|
| 37 |
+
|
| 38 |
+
errors = settings.validate()
|
| 39 |
+
if errors:
|
| 40 |
+
print("⚠️ " + "\n⚠️ ".join(errors))
|
| 41 |
+
|
| 42 |
+
kb_dir = Path(args.kb_dir)
|
| 43 |
+
if not kb_dir.exists():
|
| 44 |
+
raise SystemExit(f"Dossier introuvable : {kb_dir}")
|
| 45 |
+
|
| 46 |
+
def progress(file_name: str, lang: str) -> None:
|
| 47 |
+
print(f" ✓ {lang}/{file_name}")
|
| 48 |
+
|
| 49 |
+
print(f"📚 Ingestion de '{kb_dir}'…")
|
| 50 |
+
records = build_chunk_records(kb_dir, progress_cb=progress)
|
| 51 |
+
print(f"✅ {len(records)} chunks générés.")
|
| 52 |
+
|
| 53 |
+
out_dir = Path(args.out_dir)
|
| 54 |
+
parquet_path = save_index_locally(records, out_dir)
|
| 55 |
+
print(f"💾 Index local sauvegardé : {parquet_path}")
|
| 56 |
+
|
| 57 |
+
if args.no_push:
|
| 58 |
+
print("⏭️ Push vers le Hub désactivé (--no-push).")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
if not settings.hf_dataset_repo:
|
| 62 |
+
print(
|
| 63 |
+
"ℹ️ HF_DATASET_REPO non défini : l'index reste local uniquement. "
|
| 64 |
+
"Définissez HF_DATASET_REPO dans .env pour le publier sur le Hub."
|
| 65 |
+
)
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
print(f"☁️ Publication vers le dataset privé '{settings.hf_dataset_repo}'…")
|
| 69 |
+
url = push_index_to_hub(out_dir, settings.hf_dataset_repo, settings.hf_token)
|
| 70 |
+
print(f"✅ Dataset publié : {url}")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
main()
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fixtures partagées : aucun test ne doit faire d'appel réseau."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _fake_embed_texts(texts, kind="passage", dim: int = 256):
|
| 14 |
+
"""
|
| 15 |
+
Embeddings factices, sans aucun appel réseau, mais qui restent
|
| 16 |
+
topiquement pertinents : sac-de-mots haché (bag-of-hashed-words).
|
| 17 |
+
Deux textes qui partagent du vocabulaire (même langue, même sujet)
|
| 18 |
+
obtiennent une similarité cosinus plus élevée que deux textes qui n'en
|
| 19 |
+
partagent pas (ce qui suffit à tester le classement du retrieval, sans
|
| 20 |
+
prétendre reproduire un vrai modèle sémantique multilingue).
|
| 21 |
+
"""
|
| 22 |
+
import hashlib
|
| 23 |
+
import re
|
| 24 |
+
|
| 25 |
+
token_re = re.compile(r"\w+", re.UNICODE)
|
| 26 |
+
vectors = []
|
| 27 |
+
for t in texts:
|
| 28 |
+
vec = np.zeros(dim, dtype="float32")
|
| 29 |
+
for token in token_re.findall(t.lower()):
|
| 30 |
+
idx = int(hashlib.md5(token.encode("utf-8")).hexdigest(), 16) % dim
|
| 31 |
+
vec[idx] += 1.0
|
| 32 |
+
norm = np.linalg.norm(vec)
|
| 33 |
+
vectors.append(vec / norm if norm else vec)
|
| 34 |
+
return np.vstack(vectors)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.fixture
|
| 38 |
+
def fake_embed_fn():
|
| 39 |
+
"""Fonction batch (list[str]) -> list[list[float]] pour l'ingestion."""
|
| 40 |
+
def _fn(texts):
|
| 41 |
+
return _fake_embed_texts(texts).tolist()
|
| 42 |
+
return _fn
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.fixture(autouse=True)
|
| 46 |
+
def _no_network_embeddings(monkeypatch):
|
| 47 |
+
"""Empêche tout appel réseau accidentel vers l'API d'embeddings."""
|
| 48 |
+
import core.retrieval.embeddings as embeddings_module
|
| 49 |
+
|
| 50 |
+
def _guard(*_args, **_kwargs):
|
| 51 |
+
raise AssertionError(
|
| 52 |
+
"Un test a tenté un appel réseau réel vers l'API d'embeddings — "
|
| 53 |
+
"utilisez fake_embed_fn / monkeypatch."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
monkeypatch.setattr(embeddings_module, "embed_texts", _guard, raising=False)
|
tests/test_chunker.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from core.ingestion.chunker import chunk_text, split_into_sentences
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_split_into_sentences_basic():
|
| 5 |
+
text = "Phrase un. Phrase deux ! Phrase trois ?"
|
| 6 |
+
sentences = split_into_sentences(text)
|
| 7 |
+
assert len(sentences) == 3
|
| 8 |
+
assert sentences[0] == "Phrase un."
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_split_into_sentences_arabic_punctuation():
|
| 12 |
+
text = "هذه جملة أولى؟ هذه جملة ثانية."
|
| 13 |
+
sentences = split_into_sentences(text)
|
| 14 |
+
assert len(sentences) == 2
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_chunk_text_respects_size_and_overlap():
|
| 18 |
+
text = " ".join(f"mot{i}." for i in range(200))
|
| 19 |
+
chunks = chunk_text(text, chunk_size_tokens=50, overlap_tokens=10)
|
| 20 |
+
assert len(chunks) > 1
|
| 21 |
+
# chaque chunk (sauf peut-être le dernier) reste proche de la taille cible
|
| 22 |
+
for c in chunks[:-1]:
|
| 23 |
+
assert 0 < len(c.text.split()) <= 70
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_chunk_text_empty_returns_single_empty_chunk():
|
| 27 |
+
chunks = chunk_text("", chunk_size_tokens=100, overlap_tokens=10)
|
| 28 |
+
assert len(chunks) == 1
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_chunk_text_short_text_single_chunk():
|
| 32 |
+
chunks = chunk_text("Une phrase courte.", chunk_size_tokens=350, overlap_tokens=60)
|
| 33 |
+
assert len(chunks) == 1
|
| 34 |
+
assert "phrase courte" in chunks[0].text
|
tests/test_indexer.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from core.ingestion.indexer import build_chunk_records, save_index_locally
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_build_chunk_records_covers_all_languages(fake_embed_fn, tmp_path):
|
| 7 |
+
kb_dir = Path("knowledge_base")
|
| 8 |
+
records = build_chunk_records(kb_dir, embed_fn=fake_embed_fn)
|
| 9 |
+
|
| 10 |
+
assert len(records) >= 6 # au moins un chunk par langue échantillon
|
| 11 |
+
languages = {r.language for r in records}
|
| 12 |
+
assert languages == {"en", "fr", "ru", "es", "ar", "pt"}
|
| 13 |
+
|
| 14 |
+
# chaque enregistrement porte des métadonnées complètes et un embedding
|
| 15 |
+
for r in records:
|
| 16 |
+
assert r.chunk_id
|
| 17 |
+
assert r.document_id
|
| 18 |
+
assert r.source_file
|
| 19 |
+
assert r.text.strip()
|
| 20 |
+
assert len(r.embedding) > 0
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_save_index_locally_writes_parquet_and_manifest(fake_embed_fn, tmp_path):
|
| 24 |
+
records = build_chunk_records(Path("knowledge_base"), embed_fn=fake_embed_fn)
|
| 25 |
+
out_dir = tmp_path / "index"
|
| 26 |
+
parquet_path = save_index_locally(records, out_dir)
|
| 27 |
+
|
| 28 |
+
assert parquet_path.exists()
|
| 29 |
+
assert (out_dir / "manifest.json").exists()
|
tests/test_language_detector.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from core.ingestion.language_detector import detect_language, language_label
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_detect_french():
|
| 5 |
+
lang, conf = detect_language("Quels sont les délais de remboursement ?")
|
| 6 |
+
assert lang == "fr"
|
| 7 |
+
assert conf > 0.5
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_detect_russian():
|
| 11 |
+
lang, conf = detect_language("Какой срок возврата средств?")
|
| 12 |
+
assert lang == "ru"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_detect_arabic():
|
| 16 |
+
lang, conf = detect_language("ما هي مدة استرداد الأموال؟")
|
| 17 |
+
assert lang == "ar"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_detect_english():
|
| 21 |
+
lang, conf = detect_language("What is the refund deadline?")
|
| 22 |
+
assert lang == "en"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_detect_empty_text_is_unknown():
|
| 26 |
+
lang, conf = detect_language("")
|
| 27 |
+
assert lang == "unknown"
|
| 28 |
+
assert conf == 0.0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_language_label_known_and_unknown():
|
| 32 |
+
assert language_label("fr") == "Français"
|
| 33 |
+
assert language_label("xx") == "xx"
|
tests/test_metrics_logger.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from core.metrics import logger as metrics_logger
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_log_query_and_summary(tmp_path, monkeypatch):
|
| 7 |
+
db_path = tmp_path / "metrics.sqlite3"
|
| 8 |
+
monkeypatch.setattr(metrics_logger.settings, "metrics_db_path", str(db_path))
|
| 9 |
+
|
| 10 |
+
metrics_logger.log_query(
|
| 11 |
+
question="Quels sont les délais de remboursement ?",
|
| 12 |
+
query_language="fr",
|
| 13 |
+
target_language="fr",
|
| 14 |
+
confidence_score=0.8,
|
| 15 |
+
is_sufficient=True,
|
| 16 |
+
used_fallback=False,
|
| 17 |
+
retrieval_latency_ms=120.0,
|
| 18 |
+
generation_latency_ms=450.0,
|
| 19 |
+
sources=[{"file": "a.pdf", "language": "fr", "page": 1, "score": 0.8}],
|
| 20 |
+
)
|
| 21 |
+
metrics_logger.log_query(
|
| 22 |
+
question="Какой срок?",
|
| 23 |
+
query_language="ru",
|
| 24 |
+
target_language="ru",
|
| 25 |
+
confidence_score=0.1,
|
| 26 |
+
is_sufficient=False,
|
| 27 |
+
used_fallback=True,
|
| 28 |
+
retrieval_latency_ms=90.0,
|
| 29 |
+
generation_latency_ms=0.0,
|
| 30 |
+
sources=[],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
summary = metrics_logger.summary_metrics()
|
| 34 |
+
assert summary["total_queries"] == 2
|
| 35 |
+
assert summary["abstention_rate"] == 0.5
|
| 36 |
+
assert "fr" in summary["by_query_language"]
|
| 37 |
+
|
| 38 |
+
recent = metrics_logger.fetch_recent(limit=10)
|
| 39 |
+
assert len(recent) == 2
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vérifie l'optimisation de cache au niveau de `core.pipeline.ask` : une
|
| 3 |
+
même question (identique, même langue de réponse, même filtre) posée deux
|
| 4 |
+
fois ne doit déclencher qu'UN SEUL appel à la recherche et à la génération.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import core.pipeline as pipeline
|
| 9 |
+
from core.generation.answer_generator import GenerationResult
|
| 10 |
+
from core.metrics import logger as metrics_logger
|
| 11 |
+
from core.retrieval.hybrid_search import RetrievalResult
|
| 12 |
+
from core.retrieval.vector_store import SearchHit
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _isolate_metrics_db(monkeypatch, tmp_path):
|
| 16 |
+
monkeypatch.setattr(
|
| 17 |
+
metrics_logger.settings, "metrics_db_path", str(tmp_path / "metrics.sqlite3")
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _fake_retrieval() -> RetrievalResult:
|
| 22 |
+
hit = SearchHit(
|
| 23 |
+
chunk_id="fr_x_p1_c0",
|
| 24 |
+
document_id="fr_x",
|
| 25 |
+
source_file="x.md",
|
| 26 |
+
page=1,
|
| 27 |
+
language="fr",
|
| 28 |
+
title="X",
|
| 29 |
+
text_direction="ltr",
|
| 30 |
+
text="Le remboursement est effectué sous 14 jours.",
|
| 31 |
+
score=0.9,
|
| 32 |
+
)
|
| 33 |
+
return RetrievalResult(
|
| 34 |
+
hits=[hit],
|
| 35 |
+
query_language="fr",
|
| 36 |
+
query_language_confidence=0.95,
|
| 37 |
+
confidence_score=0.9,
|
| 38 |
+
is_sufficient=True,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_ask_uses_cache_on_repeated_question(monkeypatch, tmp_path):
|
| 43 |
+
_isolate_metrics_db(monkeypatch, tmp_path)
|
| 44 |
+
pipeline.answer_cache.clear()
|
| 45 |
+
call_counter = {"retrieve": 0, "generate": 0}
|
| 46 |
+
|
| 47 |
+
def fake_retrieve(question, store, language_filter=None, forced_query_language=None):
|
| 48 |
+
call_counter["retrieve"] += 1
|
| 49 |
+
return _fake_retrieval()
|
| 50 |
+
|
| 51 |
+
def fake_generate_answer(question, retrieval, target_language):
|
| 52 |
+
call_counter["generate"] += 1
|
| 53 |
+
return GenerationResult(
|
| 54 |
+
answer="Réponse factice.",
|
| 55 |
+
used_fallback=False,
|
| 56 |
+
latency_seconds=0.01,
|
| 57 |
+
sources=[{"file": "x.md", "language": "fr", "page": 1, "score": 0.9}],
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
monkeypatch.setattr(pipeline, "retrieve", fake_retrieve)
|
| 61 |
+
monkeypatch.setattr(pipeline, "generate_answer", fake_generate_answer)
|
| 62 |
+
|
| 63 |
+
store = object() # non utilisé par les fakes ci-dessus
|
| 64 |
+
|
| 65 |
+
first = pipeline.ask("Quels sont les délais ?", store, "auto", None)
|
| 66 |
+
second = pipeline.ask("Quels sont les délais ?", store, "auto", None)
|
| 67 |
+
|
| 68 |
+
assert first.from_cache is False
|
| 69 |
+
assert second.from_cache is True
|
| 70 |
+
assert call_counter["retrieve"] == 1
|
| 71 |
+
assert call_counter["generate"] == 1
|
| 72 |
+
assert second.generation.answer == "Réponse factice."
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_ask_does_not_reuse_cache_for_different_language_filter(monkeypatch, tmp_path):
|
| 76 |
+
_isolate_metrics_db(monkeypatch, tmp_path)
|
| 77 |
+
pipeline.answer_cache.clear()
|
| 78 |
+
call_counter = {"retrieve": 0}
|
| 79 |
+
|
| 80 |
+
def fake_retrieve(question, store, language_filter=None, forced_query_language=None):
|
| 81 |
+
call_counter["retrieve"] += 1
|
| 82 |
+
return _fake_retrieval()
|
| 83 |
+
|
| 84 |
+
def fake_generate_answer(question, retrieval, target_language):
|
| 85 |
+
return GenerationResult(
|
| 86 |
+
answer="Réponse.", used_fallback=False, latency_seconds=0.01, sources=[]
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
monkeypatch.setattr(pipeline, "retrieve", fake_retrieve)
|
| 90 |
+
monkeypatch.setattr(pipeline, "generate_answer", fake_generate_answer)
|
| 91 |
+
|
| 92 |
+
store = object()
|
| 93 |
+
pipeline.ask("Question ?", store, "auto", None)
|
| 94 |
+
pipeline.ask("Question ?", store, "auto", "fr")
|
| 95 |
+
|
| 96 |
+
assert call_counter["retrieve"] == 2
|
tests/test_prompts.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from core.generation.prompts import build_messages, format_context, no_context_message
|
| 2 |
+
from core.retrieval.vector_store import SearchHit
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _sample_hits():
|
| 6 |
+
return [
|
| 7 |
+
SearchHit(
|
| 8 |
+
chunk_id="fr_refund_p04_c03",
|
| 9 |
+
document_id="refund_policy_fr",
|
| 10 |
+
source_file="politique_remboursement_fr.pdf",
|
| 11 |
+
page=4,
|
| 12 |
+
language="fr",
|
| 13 |
+
title="Politique de remboursement",
|
| 14 |
+
text_direction="ltr",
|
| 15 |
+
text="Le remboursement est effectué dans un délai maximal de 14 jours.",
|
| 16 |
+
score=0.81,
|
| 17 |
+
)
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_format_context_includes_language_and_page():
|
| 22 |
+
ctx = format_context(_sample_hits())
|
| 23 |
+
assert "Langue : fr" in ctx
|
| 24 |
+
assert "Page : 4" in ctx
|
| 25 |
+
assert "14 jours" in ctx
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_build_messages_targets_requested_language():
|
| 29 |
+
messages = build_messages("Какой срок?", _sample_hits(), target_language="ru")
|
| 30 |
+
assert messages[0]["role"] == "system"
|
| 31 |
+
assert "Русский" in messages[0]["content"]
|
| 32 |
+
assert "Какой срок?" in messages[1]["content"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_no_context_message_falls_back_to_english_for_unknown_language():
|
| 36 |
+
msg = no_context_message("de") # allemand non supporté
|
| 37 |
+
assert msg == no_context_message("en")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_no_context_message_is_localized():
|
| 41 |
+
assert "délais" not in no_context_message("ar")
|