nvikou commited on
Commit
707993f
·
verified ·
1 Parent(s): de85f8a

Déploiement PolyglotRAG via notebook Colab

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +16 -0
  2. .env.example +55 -0
  3. .gitignore +14 -0
  4. Dockerfile +37 -0
  5. LICENSE +21 -0
  6. README.md +286 -5
  7. app.py +66 -0
  8. config.py +153 -0
  9. core/__init__.py +2 -0
  10. core/assets/architecture.svg +180 -0
  11. core/generation/__init__.py +0 -0
  12. core/generation/answer_generator.py +77 -0
  13. core/generation/prompts.py +63 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/chunker.py +77 -0
  16. core/ingestion/indexer.py +198 -0
  17. core/ingestion/language_detector.py +58 -0
  18. core/ingestion/loaders.py +119 -0
  19. core/metrics/__init__.py +0 -0
  20. core/metrics/logger.py +172 -0
  21. core/optimization/__init__.py +0 -0
  22. core/optimization/cache.py +70 -0
  23. core/pipeline.py +106 -0
  24. core/retrieval/__init__.py +0 -0
  25. core/retrieval/embeddings.py +94 -0
  26. core/retrieval/hybrid_search.py +68 -0
  27. core/retrieval/vector_store.py +184 -0
  28. core/ui/__init__.py +0 -0
  29. core/ui/admin_tab.py +136 -0
  30. core/ui/architecture_tab.py +42 -0
  31. core/ui/chat_tab.py +121 -0
  32. core/ui/theme.py +39 -0
  33. docker-compose.yml +15 -0
  34. knowledge_base/ar/سياسة_الاسترجاع_ar.md +30 -0
  35. knowledge_base/en/employee_handbook_en.md +33 -0
  36. knowledge_base/es/politica_devoluciones_es.md +33 -0
  37. knowledge_base/fr/politique_remboursement_fr.md +35 -0
  38. knowledge_base/pt/politica_de_reembolso_pt.md +32 -0
  39. knowledge_base/ru/faq_ru.md +33 -0
  40. pytest.ini +4 -0
  41. requirements.txt +13 -0
  42. scripts/ingest.py +74 -0
  43. tests/__init__.py +0 -0
  44. tests/conftest.py +56 -0
  45. tests/test_chunker.py +34 -0
  46. tests/test_indexer.py +29 -0
  47. tests/test_language_detector.py +33 -0
  48. tests/test_metrics_logger.py +39 -0
  49. tests/test_pipeline.py +96 -0
  50. 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: Polyglot Rag
3
- emoji: 🏢
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")