chore: Clean up obsolete files and add Puppeteer chat test

- Remove obsolete documentation, examples, and utility scripts
- Remove temporary screenshots and test files from root
- Add test_chat_backend.js for Puppeteer testing of chat RAG
- Update .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 21:40:56 +01:00
parent 1bf570e201
commit 2a8098f17a
59 changed files with 243 additions and 12008 deletions

View File

@@ -1,89 +0,0 @@
╔══════════════════════════════════════════════════════════════════════════════╗
║ REFACTORISATION TERMINÉE - MODE SUMMARY INTÉGRÉ ║
╚══════════════════════════════════════════════════════════════════════════════╝
✅ L'option "Résumés uniquement" est maintenant intégrée dans le dropdown!
┌──────────────────────────────────────────────────────────────────────────────┐
│ COMMENT UTILISER │
└──────────────────────────────────────────────────────────────────────────────┘
1. Ouvrir http://localhost:5000/search
2. Entrer votre question
3. Sélectionner le mode de recherche:
┌────────────────────────────────────┐
│ Mode de recherche: │
│ ┌────────────────────────────────┐ │
│ │ 🤖 Auto-détection ▼│ │
│ │ 📄 Simple (Chunks) │ │
│ │ 🌳 Hiérarchique (Summary→Chunk)│ │
│ │ 📚 Résumés uniquement (90%) ◄─┼─── NOUVEAU!
│ └────────────────────────────────┘ │
└────────────────────────────────────┘
4. Cliquer "Rechercher"
┌──────────────────────────────────────────────────────────────────────────────┐
│ CHANGEMENTS │
└──────────────────────────────────────────────────────────────────────────────┘
AVANT: 2 pages séparées (/search + /search/summary)
APRÈS: 1 seule page avec dropdown intégré
❌ Page /search/summary supprimée
✅ Option dans dropdown de /search
┌──────────────────────────────────────────────────────────────────────────────┐
│ MODES DISPONIBLES │
└──────────────────────────────────────────────────────────────────────────────┘
🤖 Auto-détection: Choix automatique (recommandé)
📄 Simple: Recherche directe dans chunks (10% visibilité)
🌳 Hiérarchique: Summary → Chunks en 2 étapes
📚 Résumés uniquement: Summary seulement (90% visibilité) ⭐
┌──────────────────────────────────────────────────────────────────────────────┐
│ TESTS │
└──────────────────────────────────────────────────────────────────────────────┘
> python test_summary_dropdown.py
✅ 14/14 tests passés (100%)
┌──────────────────────────────────────────────────────────────────────────────┐
│ EXEMPLES │
└──────────────────────────────────────────────────────────────────────────────┘
URL: http://localhost:5000/search?q=test&mode=summary
Requêtes testées:
🟣 "What is the Turing test?" → Haugeland ✅
🟢 "Can virtue be taught?" → Platon ✅
🟡 "What is pragmatism according to Peirce?" → Tiercelin ✅
┌──────────────────────────────────────────────────────────────────────────────┐
│ PERFORMANCES │
└──────────────────────────────────────────────────────────────────────────────┘
Mode Simple: 10% visibilité ❌
Mode Hiérarchique: Variable
Mode Summary: 90% visibilité ✅
Temps de réponse: ~300ms (identique tous modes)
┌──────────────────────────────────────────────────────────────────────────────┐
│ DOCUMENTATION │
└──────────────────────────────────────────────────────────────────────────────┘
REFACTOR_SUMMARY.md - Documentation complète de la refactorisation
test_summary_dropdown.py - Tests automatisés (14 checks)
QUICKSTART_REFACTOR.txt - Ce fichier
╔══════════════════════════════════════════════════════════════════════════════╗
║ REFACTORISATION COMPLÈTE ET TESTÉE ║
║ -370 lignes de code ║
║ Architecture plus propre ║
║ UX simplifiée ║
╚══════════════════════════════════════════════════════════════════════════════╝

View File

@@ -1,91 +0,0 @@
╔══════════════════════════════════════════════════════════════════════════════╗
║ INTÉGRATION FLASK - RECHERCHE SUMMARY ║
║ Date: 2026-01-03 ║
╚══════════════════════════════════════════════════════════════════════════════╝
✅ INTÉGRATION COMPLÈTE ET TESTÉE
┌──────────────────────────────────────────────────────────────────────────────┐
│ DÉMARRAGE RAPIDE │
└──────────────────────────────────────────────────────────────────────────────┘
1. Démarrer Weaviate:
> docker compose up -d
2. Lancer Flask:
> cd generations/library_rag
> python flask_app.py
3. Ouvrir navigateur:
http://localhost:5000
4. Cliquer menu ☰ → "📚 Recherche Résumés" (badge 90%)
┌──────────────────────────────────────────────────────────────────────────────┐
│ FICHIERS MODIFIÉS │
└──────────────────────────────────────────────────────────────────────────────┘
✓ flask_app.py [+140 lignes: fonction + route]
✓ templates/search_summary.html [NOUVEAU: interface complète]
✓ templates/base.html [Navigation mise à jour]
┌──────────────────────────────────────────────────────────────────────────────┐
│ TESTS │
└──────────────────────────────────────────────────────────────────────────────┘
> python test_flask_integration.py
Résultat: ✅ 12/12 tests passés (100%)
┌──────────────────────────────────────────────────────────────────────────────┐
│ PERFORMANCE │
└──────────────────────────────────────────────────────────────────────────────┘
Recherche Summary (Nouveau): 90% de visibilité ✅
Recherche Chunk (Ancien): 10% de visibilité ❌
Amélioration: +800%
┌──────────────────────────────────────────────────────────────────────────────┐
│ EXEMPLES DE REQUÊTES │
└──────────────────────────────────────────────────────────────────────────────┘
🟣 IA: "What is the Turing test?"
🟢 Platon: "Can virtue be taught?"
🟡 Pragmatisme: "What is pragmatism according to Peirce?"
┌──────────────────────────────────────────────────────────────────────────────┐
│ DOCUMENTATION │
└──────────────────────────────────────────────────────────────────────────────┘
Guide rapide: QUICKSTART_SUMMARY_SEARCH.md
Intégration technique: INTEGRATION_SUMMARY.md
Analyse complète: ANALYSE_RAG_FINAL.md
Session complète: COMPLETE_SESSION_RECAP.md
┌──────────────────────────────────────────────────────────────────────────────┐
│ NAVIGATION WEB │
└──────────────────────────────────────────────────────────────────────────────┘
URL directe: http://localhost:5000/search/summary
Paramètres:
?q=votre+question
&limit=10 (5, 10, 15, 20)
&min_similarity=0.65 (0.60, 0.65, 0.70, 0.75)
┌──────────────────────────────────────────────────────────────────────────────┐
│ STATUT │
└──────────────────────────────────────────────────────────────────────────────┘
✅ Backend: Fonctionnel
✅ Frontend: Intégré
✅ Tests: 100% passés
✅ Documentation: Complète
✅ Production: Ready
ROI: +800% de visibilité pour $1.23 d'investissement
╔══════════════════════════════════════════════════════════════════════════════╗
║ FIN DE L'INTÉGRATION ║
╚══════════════════════════════════════════════════════════════════════════════╝

View File

@@ -1,80 +0,0 @@
# ═══════════════════════════════════════════════════════════════════════════════
# Works Filter API
# ═══════════════════════════════════════════════════════════════════════════════
@app.route("/api/get-works")
def api_get_works() -> WerkzeugResponse:
"""Get list of all available works with metadata for filtering.
Returns a JSON array of all unique works in the database, sorted by author
then title. Each work includes the title, author, and number of chunks.
Returns:
JSON response with array of works:
[
{"title": "Ménon", "author": "Platon", "chunks_count": 127},
...
]
Raises:
500: If Weaviate connection fails or query errors occur.
Example:
GET /api/get-works
Returns: [{"title": "Ménon", "author": "Platon", "chunks_count": 127}, ...]
"""
try:
with get_weaviate_client() as client:
if client is None:
return jsonify({
"error": "Weaviate connection failed",
"message": "Cannot connect to Weaviate database"
}), 500
# Query Chunk collection to get all unique works with counts
chunks = client.collections.get("Chunk")
# Fetch all chunks to aggregate by work
# Using a larger limit to get all documents
all_chunks = chunks.query.fetch_objects(
limit=10000,
return_properties=["work"]
)
# Aggregate chunks by work (title + author)
works_count: Dict[str, Dict[str, Any]] = {}
for obj in all_chunks.objects:
work_obj = obj.properties.get("work")
if work_obj and isinstance(work_obj, dict):
title = work_obj.get("title", "")
author = work_obj.get("author", "")
if title: # Only count if title exists
# Use title as key (assumes unique titles)
if title not in works_count:
works_count[title] = {
"title": title,
"author": author or "Unknown",
"chunks_count": 0
}
works_count[title]["chunks_count"] += 1
# Convert to list and sort by author, then title
works_list = list(works_count.values())
works_list.sort(key=lambda w: (w["author"].lower(), w["title"].lower()))
print(f"[API] /api/get-works: Found {len(works_list)} unique works")
return jsonify(works_list)
except Exception as e:
print(f"[API] /api/get-works error: {e}")
return jsonify({
"error": "Database query failed",
"message": str(e)
}), 500

View File

@@ -1,650 +0,0 @@
<project_specification>
<project_name>Library RAG - Filtrage par œuvres dans la conversation</project_name>
<overview>
Système de filtrage par œuvres pour la page de conversation RAG, permettant aux utilisateurs de sélectionner les œuvres sur lesquelles effectuer la recherche sémantique.
**Objectif :**
Ajouter une section "Filtrer par œuvres" dans la sidebar droite (au-dessus du "Contexte RAG") avec des cases à cocher pour chaque œuvre disponible. Chaque message de la conversation recherchera uniquement dans les œuvres sélectionnées.
**Architecture :**
- Backend : Nouvelle route API + modification de la recherche Weaviate avec filtres
- Frontend : Nouvelle section UI avec checkboxes + JavaScript pour état et persistance
- Persistance : localStorage pour sauvegarder la sélection entre les sessions
**Comportement par défaut :**
- Toutes les œuvres cochées au démarrage
- Section collapsible avec chevron
- Boutons "Tout" / "Aucun" pour sélection rapide
- Badge compteur : "X/Y sélectionnées"
- Responsive mobile : section pliable au-dessus de l'input
**Contraintes :**
- Ne pas modifier l'export Word/PDF existant
- Conserver la structure grid 60%/40% (conversation/sidebar)
- Compatibilité avec les 3 modes de recherche (simple, hiérarchique, summary)
</overview>
<technology_stack>
<backend>
<framework>Flask 3.0+</framework>
<database>Weaviate 1.34.4 + text2vec-transformers (BGE-M3)</database>
<python>Python 3.10+</python>
</backend>
<frontend>
<template_engine>Jinja2</template_engine>
<javascript>Vanilla JavaScript (ES6+)</javascript>
<css>Custom CSS (variables CSS existantes)</css>
<storage>localStorage (Web Storage API)</storage>
</frontend>
</technology_stack>
<implementation_steps>
<feature_1>
<title>Backend - Route API /api/get-works</title>
<description>
Créer une route GET qui retourne la liste de toutes les œuvres disponibles avec métadonnées.
Tasks:
- Ajouter route @app.route("/api/get-works") dans flask_app.py (après ligne ~1380)
- Utiliser get_weaviate_client() context manager
- Query collection "Chunk" pour extraire toutes les œuvres uniques
- Parser propriété nested "work" (work.title, work.author)
- Compter les chunks par œuvre (chunks_count)
- Retourner JSON trié par auteur puis titre
- Gérer les erreurs de connexion Weaviate
- Ajouter logging pour debug
Format de sortie JSON :
[
{
"title": "Ménon",
"author": "Platon",
"chunks_count": 127
},
...
]
</description>
<priority>1</priority>
<category>backend</category>
<test_steps>
1. Démarrer Weaviate : docker compose up -d
2. Démarrer Flask : python flask_app.py
3. Tester route : curl http://localhost:5000/api/get-works
4. Vérifier JSON retourné contient toutes les œuvres
5. Vérifier tri alphabétique par auteur
6. Vérifier chunks_count > 0 pour chaque œuvre
7. Tester avec Weaviate arrêté : vérifier erreur 500 propre
</test_steps>
</feature_1>
<feature_2>
<title>Backend - Modification route /chat/send</title>
<description>
Modifier la route POST /chat/send pour accepter le paramètre selected_works et le passer à la fonction de recherche.
Tasks:
- Localiser route @app.route("/chat/send", methods=["POST"]) (ligne ~1756)
- Ajouter extraction paramètre selected_works du JSON body
- Type : List[str] (liste des titres d'œuvres)
- Valeur par défaut : [] (liste vide = toutes les œuvres)
- Valider que selected_works est bien une liste
- Passer selected_works à la fonction de recherche sémantique
- Logger les œuvres sélectionnées pour debug
- Gérer cas où selected_works = [] (pas de filtre)
Exemple JSON input :
{
"question": "Qu'est-ce que la justice ?",
"provider": "openai",
"model": "gpt-4o",
"limit": 5,
"selected_works": ["Ménon", "La pensée-signe"]
}
</description>
<priority>1</priority>
<category>backend</category>
<test_steps>
1. Modifier /chat/send pour accepter selected_works
2. Tester POST avec selected_works vide : curl -X POST -H "Content-Type: application/json" -d '{"question":"test","provider":"openai","model":"gpt-4o","selected_works":[]}' http://localhost:5000/chat/send
3. Vérifier que la recherche fonctionne sans filtre
4. Tester POST avec selected_works = ["Ménon"]
5. Vérifier que le paramètre est bien reçu (ajouter print/log temporaire)
6. Tester POST avec selected_works invalide (pas une liste)
7. Vérifier gestion d'erreur propre
</test_steps>
</feature_2>
<feature_3>
<title>Backend - Filtre Weaviate par œuvres</title>
<description>
Implémenter la logique de filtrage Weaviate pour rechercher uniquement dans les œuvres sélectionnées.
Tasks:
- Localiser la fonction de recherche sémantique dans /chat/send
- Identifier les requêtes Weaviate (near_text sur Chunk et Summary)
- Ajouter paramètre selected_works à ces fonctions
- Construire filtre Weaviate si selected_works non vide :
- Pour Chunk : Filter.by_property("work").by_property("title").contains_any(selected_works)
- Pour Summary : Idem si Summary a work nested
- Appliquer filtre dans :
- Recherche simple (Chunk.query.near_text)
- Recherche hiérarchique (Summary → Chunk)
- Recherche summary uniquement
- Tester syntaxe filtre Weaviate v4 (contains_any sur nested property)
- Gérer cas où aucun résultat trouvé avec filtre
- Logger requêtes Weaviate pour debug
Note critique : Weaviate v4 syntax pour nested properties
</description>
<priority>1</priority>
<category>backend</category>
<test_steps>
1. Identifier fonction de recherche actuelle dans flask_app.py
2. Ajouter filtre Weaviate pour selected_works
3. Tester recherche sans filtre (selected_works=[])
4. Vérifier résultats de toutes les œuvres
5. Tester recherche avec filtre (selected_works=["Ménon"])
6. Vérifier que SEULS les chunks de Ménon sont retournés
7. Tester avec œuvre inexistante : vérifier 0 résultats
8. Tester mode hiérarchique avec filtre
9. Vérifier que Summary ET Chunk sont filtrés
</test_steps>
</feature_3>
<feature_4>
<title>Frontend - HTML section filtrage œuvres</title>
<description>
Créer la section HTML "Filtrer par œuvres" dans la sidebar droite, au-dessus du "Contexte RAG".
Tasks:
- Ouvrir templates/chat.html
- Localiser ligne ~710 (début de .context-sidebar)
- AVANT la div .context-sidebar, ajouter nouvelle div .works-filter-section
- Structure HTML :
- Header avec titre + badge compteur + bouton collapse
- Content avec boutons "Tout"/"Aucun"
- Div .works-list (remplie dynamiquement par JS)
- IDs pour JavaScript :
- works-filter-section
- works-filter-content
- works-collapse-btn
- works-count-badge
- works-list
- select-all-works
- select-none-works
- Classe sidebar-empty pour état de chargement
- Respecter structure HTML existante (sidebar-header, sidebar-content)
Note : Ne pas modifier la div .context-sidebar existante
</description>
<priority>1</priority>
<category>frontend</category>
<test_steps>
1. Ouvrir http://localhost:5000/chat dans le navigateur
2. Vérifier que nouvelle section apparaît AU-DESSUS du Contexte RAG
3. Vérifier header avec titre "📚 Filtrer par œuvres"
4. Vérifier badge compteur visible
5. Vérifier bouton collapse (chevron ▼)
6. Vérifier boutons "Tout" et "Aucun" présents
7. Vérifier div .works-list vide au démarrage
8. Vérifier que la section Contexte RAG est toujours visible en-dessous
</test_steps>
</feature_4>
<feature_5>
<title>Frontend - CSS pour section filtrage</title>
<description>
Ajouter les styles CSS pour la section de filtrage par œuvres, cohérents avec le design existant.
Tasks:
- Dans templates/chat.html, section &lt;style&gt; (ligne ~6)
- Ajouter styles pour :
- .works-filter-section (structure générale)
- .works-filter-content (max-height 250px, scroll)
- .works-count-badge (badge compteur)
- .works-filter-actions (boutons Tout/Aucun)
- .btn-mini (style boutons)
- .works-list (liste des œuvres)
- .work-item (chaque œuvre)
- .work-checkbox (case à cocher)
- .work-info (titre + auteur)
- .work-title, .work-author (typographie)
- .work-count (badge nombre de passages)
- Utiliser variables CSS existantes :
- --color-accent, --color-accent-alt
- --color-text-main, --color-text-strong
- --font-body, --font-title
- Ajouter hover effects
- Responsive : @media (max-width: 992px) pour mobile
- Cohérence avec .context-sidebar existante
Note : Réutiliser les styles de .context-chunk pour cohérence
</description>
<priority>1</priority>
<category>frontend</category>
<test_steps>
1. Recharger page /chat
2. Vérifier styles appliqués sur section filtrage
3. Vérifier bordures, border-radius cohérents
4. Vérifier couleurs cohérentes avec palette existante
5. Tester hover sur boutons "Tout"/"Aucun"
6. Tester hover sur work-item
7. Vérifier scrollbar sur .works-list si >250px
8. Tester responsive : réduire fenêtre < 992px
9. Vérifier que section est collapsible sur mobile
</test_steps>
</feature_5>
<feature_6>
<title>Frontend - JavaScript état et rendu</title>
<description>
Implémenter la logique JavaScript pour gérer l'état des œuvres sélectionnées et le rendu de la liste.
Tasks:
- Dans templates/chat.html, section &lt;script&gt; (après ligne ~732)
- Déclarer variables globales :
- availableWorks: Array&lt;Work&gt; (liste complète)
- selectedWorks: Array&lt;string&gt; (titres sélectionnés)
- Créer fonction loadAvailableWorks() :
- Fetch GET /api/get-works
- Stocker dans availableWorks
- Initialiser selectedWorks (tous par défaut ou localStorage)
- Appeler renderWorksList()
- Créer fonction renderWorksList() :
- Vider works-list
- Pour chaque work, créer HTML :
- Checkbox (checked si dans selectedWorks)
- work-info (titre + auteur)
- work-count (nombre passages)
- Ajouter event listeners sur checkboxes
- Click sur work-item toggle checkbox
- Créer fonction toggleWorkSelection(title, isSelected)
- Créer fonction updateWorksCount()
- Appeler loadAvailableWorks() au chargement de la page
Note : Utiliser addEventListener, pas d'inline onclick
</description>
<priority>1</priority>
<category>frontend</category>
<test_steps>
1. Ouvrir console navigateur (F12)
2. Recharger page /chat
3. Vérifier que fetch /api/get-works est appelé (Network tab)
4. Vérifier que availableWorks contient les œuvres (console.log)
5. Vérifier que .works-list contient des work-item
6. Cocher/décocher une œuvre
7. Vérifier que selectedWorks est mis à jour (console.log)
8. Vérifier que badge compteur est mis à jour
9. Cliquer sur work-item (pas la checkbox)
10. Vérifier que checkbox est togglee
</test_steps>
</feature_6>
<feature_7>
<title>Frontend - JavaScript persistance localStorage</title>
<description>
Implémenter la sauvegarde automatique de la sélection dans localStorage pour persister entre les sessions.
Tasks:
- Créer fonction saveSelectedWorksToStorage() :
- localStorage.setItem('selectedWorks', JSON.stringify(selectedWorks))
- Appeler saveSelectedWorksToStorage() après chaque modification :
- Dans toggleWorkSelection()
- Dans selectAllWorksBtn click
- Dans selectNoneWorksBtn click
- Modifier loadAvailableWorks() :
- Charger localStorage.getItem('selectedWorks')
- Parser JSON si existe
- Sinon, sélectionner toutes les œuvres par défaut
- Gérer cas où œuvres ont changé :
- Filtrer selectedWorks pour ne garder que celles qui existent
- Mettre à jour localStorage
- Ajouter fonction clearWorksStorage() pour debug (optionnel)
Note : Vérifier que localStorage est disponible (try-catch)
</description>
<priority>2</priority>
<category>frontend</category>
<test_steps>
1. Ouvrir /chat et sélectionner 2 œuvres uniquement
2. Vérifier dans DevTools → Application → Local Storage
3. Vérifier clé 'selectedWorks' contient JSON des 2 œuvres
4. Rafraîchir la page (F5)
5. Vérifier que les 2 œuvres sont toujours cochées
6. Cliquer "Tout" puis rafraîchir
7. Vérifier que toutes les œuvres sont cochées
8. Cliquer "Aucun" puis rafraîchir
9. Vérifier qu'aucune œuvre n'est cochée
10. Tester en navigation privée (pas de localStorage)
</test_steps>
</feature_7>
<feature_8>
<title>Frontend - JavaScript intégration recherche</title>
<description>
Modifier la fonction startRAGSearch() pour envoyer les œuvres sélectionnées au backend lors de la recherche.
Tasks:
- Localiser fonction startRAGSearch(question, provider, model) (ligne ~943)
- Modifier le fetch POST /chat/send :
- Ajouter clé "selected_works" au JSON body
- Valeur : selectedWorks (array global)
- Ajouter validation avant envoi :
- Si selectedWorks.length === 0, afficher warning ?
- Ou laisser passer (recherche sur toutes)
- Tester que le filtre fonctionne :
- Contexte RAG affiché ne contient QUE les œuvres sélectionnées
- Réponse LLM basée uniquement sur ces œuvres
- Ajouter logging console.log pour debug
- Gérer erreur si aucun résultat trouvé avec filtre
Note : Pas besoin de modifier displayContext() si backend filtre correctement
</description>
<priority>1</priority>
<category>frontend</category>
<test_steps>
1. Ouvrir /chat
2. Sélectionner uniquement œuvre "Ménon"
3. Poser question : "Qu'est-ce que la vertu ?"
4. Vérifier dans Network tab : POST /chat/send contient selected_works: ["Ménon"]
5. Vérifier que contexte RAG ne contient QUE des chunks de Ménon
6. Vérifier que réponse LLM mentionne Ménon (pas d'autres œuvres)
7. Sélectionner 2 œuvres : "Ménon" + "La pensée-signe"
8. Poser nouvelle question
9. Vérifier contexte contient ces 2 œuvres uniquement
10. Désélectionner toutes les œuvres et tester
</test_steps>
</feature_8>
<feature_9>
<title>Frontend - Boutons actions et collapse</title>
<description>
Implémenter les boutons "Tout" / "Aucun" et le comportement de collapse de la section.
Tasks:
- Event listener selectAllWorksBtn :
- selectedWorks = availableWorks.map(w =&gt; w.title)
- Appeler renderWorksList()
- Appeler updateWorksCount()
- Appeler saveSelectedWorksToStorage()
- Event listener selectNoneWorksBtn :
- selectedWorks = []
- Appeler renderWorksList()
- Appeler updateWorksCount()
- Appeler saveSelectedWorksToStorage()
- Event listener worksCollapseBtn :
- Toggle display de works-filter-content
- Changer texte chevron (▼ / ▲)
- Changer title tooltip ("Réduire" / "Développer")
- Optionnel : sauvegarder état collapse dans localStorage
- Ajouter transition CSS pour collapse smooth
Note : Réutiliser logique existante de collapseBtn du Contexte RAG
</description>
<priority>2</priority>
<category>frontend</category>
<test_steps>
1. Ouvrir /chat
2. Cliquer bouton "Tout"
3. Vérifier que toutes les œuvres sont cochées
4. Vérifier badge compteur : "X/X sélectionnées"
5. Cliquer bouton "Aucun"
6. Vérifier qu'aucune œuvre n'est cochée
7. Vérifier badge compteur : "0/X sélectionnées"
8. Cliquer chevron collapse
9. Vérifier que .works-filter-content disparaît
10. Vérifier chevron change (▼ → ▲)
11. Recliquer chevron : section réapparaît
</test_steps>
</feature_9>
<feature_10>
<title>Frontend - Responsive mobile</title>
<description>
Adapter l'interface de filtrage pour les écrans mobiles (&lt; 992px).
Tasks:
- Ajouter @media (max-width: 992px) dans CSS
- Sur mobile :
- .works-filter-section : order: -2 (avant contexte RAG)
- max-height: 200px
- .works-filter-content : max-height: 150px
- Section collapsée par défaut
- Badge compteur plus visible
- Tester sur petits écrans :
- iPhone (375px)
- iPad (768px)
- Desktop réduit (900px)
- Vérifier que section ne prend pas trop de place
- Vérifier scroll horizontal n'apparaît pas
- Vérifier touch events fonctionnent (pas que click)
Note : La structure grid actuelle passe déjà en 1 colonne sur mobile
</description>
<priority>2</priority>
<category>frontend</category>
<test_steps>
1. Ouvrir DevTools → Toggle device toolbar (Ctrl+Shift+M)
2. Sélectionner iPhone 12 Pro (390x844)
3. Vérifier que section filtrage apparaît
4. Vérifier hauteur limitée à 200px
5. Vérifier scroll fonctionne si liste longue
6. Tester checkbox touch/click
7. Tester boutons "Tout"/"Aucun"
8. Tester collapse fonctionne
9. Passer en iPad (768px)
10. Vérifier layout correct
11. Revenir desktop (>992px) : vérifier layout normal
</test_steps>
</feature_10>
<feature_11>
<title>Testing - Tests backend routes</title>
<description>
Créer tests unitaires pour les nouvelles routes backend et la logique de filtrage.
Tasks:
- Créer tests/test_works_filter.py
- Tester route /api/get-works :
- Mock get_weaviate_client()
- Mock collection Chunk avec données test
- Vérifier JSON retourné correct
- Vérifier tri par auteur
- Vérifier chunks_count calculé
- Tester erreur Weaviate
- Tester /chat/send avec selected_works :
- Mock fonction de recherche
- Vérifier paramètre passé correctement
- Tester avec selected_works vide
- Tester avec selected_works = ["Ménon"]
- Tester logique filtre Weaviate :
- Mock Weaviate query
- Vérifier filtre contains_any appliqué
- Vérifier résultats filtrés
- Utiliser pytest et pytest-mock
- Vérifier couverture >80%
Note : Ne pas faire d'appels API réels en tests
</description>
<priority>3</priority>
<category>testing</category>
<test_steps>
1. Installer pytest : pip install pytest pytest-mock
2. Créer tests/test_works_filter.py
3. Exécuter : pytest tests/test_works_filter.py -v
4. Vérifier tous les tests passent
5. Exécuter avec coverage : pytest --cov=flask_app tests/test_works_filter.py
6. Vérifier couverture >80% pour routes concernées
7. Tester avec Weaviate mock : aucun appel réseau réel
8. Vérifier temps d'exécution <5s
</test_steps>
</feature_11>
<feature_12>
<title>Testing - Tests frontend JavaScript</title>
<description>
Tests manuels de la logique JavaScript dans le navigateur.
Tasks:
- Tester loadAvailableWorks() :
- Console : vérifier availableWorks peuplé
- Vérifier selectedWorks initialisé
- Tester renderWorksList() :
- Vérifier work-items créés dynamiquement
- Vérifier checkboxes cochées selon selectedWorks
- Tester toggleWorkSelection() :
- Cocher/décocher plusieurs œuvres
- Vérifier selectedWorks mis à jour
- Vérifier badge compteur synchronisé
- Tester localStorage :
- Modifier sélection
- Rafraîchir page
- Vérifier persistance
- Tester intégration recherche :
- Sélectionner œuvre
- Envoyer question
- Vérifier filtre appliqué
- Tester boutons Tout/Aucun
- Tester collapse
- Tester responsive
Note : Tests manuels car pas de framework test JS configuré
</description>
<priority>3</priority>
<category>testing</category>
<test_steps>
1. Ouvrir /chat avec DevTools (F12)
2. Console : taper availableWorks puis Enter
3. Vérifier array d'œuvres affiché
4. Console : taper selectedWorks puis Enter
5. Vérifier array de titres sélectionnés
6. Cocher/décocher œuvre
7. Retaper selectedWorks : vérifier mise à jour
8. Application tab → Local Storage → selectedWorks
9. Vérifier JSON synchronisé
10. Envoyer question test
11. Network tab → POST /chat/send → Preview
12. Vérifier selected_works dans payload
</test_steps>
</feature_12>
<feature_13>
<title>Documentation - Guide utilisateur</title>
<description>
Documenter la nouvelle fonctionnalité de filtrage par œuvres.
Tasks:
- Mettre à jour README.md ou créer WORKS_FILTER.md
- Documenter :
- Fonctionnalité de filtrage
- Comportement par défaut (toutes cochées)
- Boutons "Tout" / "Aucun"
- Persistance localStorage
- Impact sur la recherche sémantique
- Cas d'usage recommandés
- Ajouter captures d'écran (optionnel)
- Documenter API /api/get-works
- Documenter modification /chat/send
- Ajouter troubleshooting :
- Que faire si aucune œuvre affichée ?
- Que faire si filtre ne fonctionne pas ?
- Comment réinitialiser la sélection ?
Note : Documentation utilisateur, pas technique
</description>
<priority>3</priority>
<category>documentation</category>
<test_steps>
1. Lire README.md ou WORKS_FILTER.md
2. Vérifier clarté des explications
3. Vérifier exemples concrets fournis
4. Tester instructions étape par étape
5. Vérifier troubleshooting couvre cas courants
6. Vérifier API documentée (endpoints, params, retour)
7. Relire pour fautes orthographe/grammaire
</test_steps>
</feature_13>
</implementation_steps>
<testing_strategy>
<backend_tests>
<structure>
tests/
└── test_works_filter.py
</structure>
<coverage>
- Route /api/get-works (mock Weaviate)
- Route /chat/send avec selected_works (mock recherche)
- Logique filtre Weaviate (mock query)
- Cas limites (liste vide, œuvre inexistante)
</coverage>
</backend_tests>
<frontend_tests>
<manual_testing>
- Tests manuels dans navigateur (Chrome, Firefox)
- Tests console JavaScript
- Tests localStorage DevTools
- Tests Network DevTools
- Tests responsive (DevTools device mode)
</manual_testing>
<no_automated_js_tests>
Pas de framework de test JS (Jest, Mocha) configuré.
Tests manuels suffisants pour cette feature.
</no_automated_js_tests>
</frontend_tests>
</testing_strategy>
<success_criteria>
<functional>
- Route /api/get-works retourne toutes les œuvres avec métadonnées
- Section "Filtrer par œuvres" visible dans sidebar droite
- Checkboxes fonctionnelles pour chaque œuvre
- Sélection persiste entre les sessions (localStorage)
- Recherche filtrée uniquement sur œuvres sélectionnées
- Contexte RAG ne contient QUE les œuvres sélectionnées
- Boutons "Tout" / "Aucun" fonctionnels
- Section collapsible avec chevron
- Badge compteur synchronisé
- Responsive mobile fonctionnel
</functional>
<quality>
- Code backend suit conventions Flask existantes
- Code frontend suit conventions JavaScript existantes
- CSS cohérent avec design existant (variables CSS)
- Pas de console errors JavaScript
- Pas d'erreurs 500 backend
- Tests backend passent (>80% coverage)
</quality>
<performance>
- /api/get-works répond en <500ms
- Rendu liste œuvres <100ms
- Pas de lag lors du check/uncheck
- localStorage lecture/écriture instantanée
</performance>
<ux>
- Comportement par défaut intuitif (toutes cochées)
- Feedback visuel immédiat sur sélection
- Badge compteur toujours à jour
- Pas de confusion avec section Contexte RAG
- Mobile : section accessible et utilisable
</ux>
</success_criteria>
<deployment>
<no_deployment>
Modification de l'application Flask existante.
Pas de déploiement séparé nécessaire.
Redémarrage Flask après modifications :
- Ctrl+C pour arrêter
- python flask_app.py pour redémarrer
</no_deployment>
</deployment>
</project_specification>

View File

@@ -1,67 +0,0 @@
"""Script pour vérifier la progression de la génération de résumés."""
import json
import sys
from datetime import datetime
from pathlib import Path
import weaviate
# Fix encoding
if sys.platform == 'win32' and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
PROGRESS_FILE = Path("summary_generation_progress.json")
print("=" * 80)
print("PROGRESSION GÉNÉRATION DE RÉSUMÉS")
print("=" * 80)
# Lire la progression
if not PROGRESS_FILE.exists():
print("\n⚠ Aucune progression sauvegardée")
print(" → Lancez resume_summaries.bat pour démarrer")
sys.exit(0)
with open(PROGRESS_FILE, "r", encoding="utf-8") as f:
progress = json.load(f)
processed = progress["total_processed"]
last_update = progress.get("last_update", "N/A")
print(f"\n📊 Chunks traités : {processed}")
print(f"🕒 Dernière MAJ : {last_update}")
# Connexion Weaviate pour vérifier le total
try:
client = weaviate.connect_to_local(host="localhost", port=8080, grpc_port=50051)
chunk_collection = client.collections.get("Chunk")
all_chunks = chunk_collection.query.fetch_objects(limit=10000)
without_summary = sum(1 for obj in all_chunks.objects if not obj.properties.get("summary", ""))
total = len(all_chunks.objects)
with_summary = total - without_summary
print(f"\n📈 Total chunks : {total}")
print(f"✓ Avec résumé : {with_summary} ({with_summary/total*100:.1f}%)")
print(f"⏳ Sans résumé : {without_summary} ({without_summary/total*100:.1f}%)")
if without_summary > 0:
print(f"\n🎯 Progression estimée : {with_summary}/{total} chunks")
print(f" Reste à traiter : {without_summary} chunks")
# Estimation temps restant (basé sur 50s/chunk)
time_remaining_hours = (without_summary * 50) / 3600
print(f" ETA (~50s/chunk) : {time_remaining_hours:.1f} heures")
else:
print("\n✅ TERMINÉ ! Tous les chunks ont un résumé !")
client.close()
except Exception as e:
print(f"\n⚠ Erreur connexion Weaviate: {e}")
print("\n" + "=" * 80)
print("Pour relancer la génération : resume_summaries.bat")
print("=" * 80)

View File

@@ -1,625 +0,0 @@
# Spécifications MCP Client pour Application Python
## Vue d'ensemble
Ce document spécifie comment implémenter un client MCP dans votre application Python pour permettre à votre LLM d'utiliser les outils de Library RAG via le MCP server.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ VOTRE APPLICATION PYTHON │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ LLM │───────▶│ MCP Client │─────▶│ Tool Executor│ │
│ │ (Mistral, │◀───────│ (votre code)│◀─────│ │ │
│ │ Claude, │ └──────────────┘ └──────────────┘ │
│ │ etc.) │ │ ▲ │
│ └────────────┘ │ │ │
│ │ │ stdio (JSON-RPC) │
└───────────────────────────────┼─┼────────────────────────────────┘
│ │
┌──────┴─┴──────┐
│ MCP Server │
│ (subprocess) │
│ │
│ library_rag/ │
│ mcp_server.py │
└────────────────┘
┌──────┴──────┐
│ Weaviate │
│ Database │
└─────────────┘
```
## Composants à implémenter
### 1. MCP Client Manager
**Fichier:** `mcp_client.py`
**Responsabilités:**
- Démarrer le MCP server comme subprocess
- Communiquer via stdin/stdout (JSON-RPC 2.0)
- Gérer le cycle de vie du server
- Exposer les outils disponibles au LLM
**Interface:**
```python
class MCPClient:
"""Client pour communiquer avec le MCP server de Library RAG."""
def __init__(self, server_script_path: str, env: dict[str, str] | None = None):
"""
Args:
server_script_path: Chemin vers mcp_server.py
env: Variables d'environnement (MISTRAL_API_KEY, etc.)
"""
pass
async def start(self) -> None:
"""Démarrer le MCP server subprocess."""
pass
async def stop(self) -> None:
"""Arrêter le MCP server subprocess."""
pass
async def list_tools(self) -> list[ToolDefinition]:
"""Obtenir la liste des outils disponibles."""
pass
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any]
) -> ToolResult:
"""Appeler un outil MCP.
Args:
tool_name: Nom de l'outil (ex: "search_chunks")
arguments: Arguments JSON
Returns:
Résultat de l'outil
"""
pass
```
### 2. JSON-RPC Communication
**Format des messages:**
**Client → Server (appel d'outil):**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_chunks",
"arguments": {
"query": "nominalism and realism",
"limit": 10
}
}
}
```
**Server → Client (résultat):**
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"results\": [...], \"total_count\": 10}"
}
]
}
}
```
### 3. LLM Integration
**Fichier:** `llm_with_tools.py`
**Responsabilités:**
- Convertir les outils MCP en format utilisable par le LLM
- Gérer le cycle de reasoning + tool calling
- Parser les réponses du LLM pour extraire les appels d'outils
**Interface:**
```python
class LLMWithMCPTools:
"""LLM avec capacité d'utiliser les outils MCP."""
def __init__(
self,
llm_client, # Mistral, Anthropic, OpenAI client
mcp_client: MCPClient
):
"""
Args:
llm_client: Client LLM (Mistral, Claude, GPT)
mcp_client: Client MCP initialisé
"""
pass
async def chat(
self,
user_message: str,
max_iterations: int = 5
) -> str:
"""
Converser avec le LLM qui peut utiliser les outils MCP.
Flow:
1. Envoyer message au LLM avec liste des outils
2. Si LLM demande un outil → l'exécuter via MCP
3. Renvoyer le résultat au LLM
4. Répéter jusqu'à réponse finale
Args:
user_message: Question de l'utilisateur
max_iterations: Limite de tool calls
Returns:
Réponse finale du LLM
"""
pass
async def _convert_mcp_tools_to_llm_format(
self,
mcp_tools: list[ToolDefinition]
) -> list[dict]:
"""Convertir les outils MCP au format du LLM."""
pass
```
## Protocole de communication détaillé
### Phase 1: Initialisation
```python
# 1. Démarrer le subprocess
process = await asyncio.create_subprocess_exec(
"python", "mcp_server.py",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=environment_variables
)
# 2. Envoyer initialize request
initialize_request = {
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"clientInfo": {
"name": "my-python-app",
"version": "1.0.0"
}
}
}
# 3. Recevoir initialize response
# Server retourne ses capabilities et la liste des outils
# 4. Envoyer initialized notification
initialized_notification = {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
```
### Phase 2: Découverte des outils
```python
# Liste des outils disponibles
tools_request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
# Réponse attendue:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "search_chunks",
"description": "Search for text chunks using semantic similarity",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
"author_filter": {"type": "string"}
},
"required": ["query"]
}
},
{
"name": "parse_pdf",
"description": "Process a PDF with OCR and ingest to Weaviate",
"inputSchema": {
"type": "object",
"properties": {
"pdf_path": {"type": "string"}
},
"required": ["pdf_path"]
}
}
// ... autres outils
]
}
}
```
### Phase 3: Appel d'outil
```python
# Appel d'outil
tool_call_request = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "search_chunks",
"arguments": {
"query": "What is nominalism?",
"limit": 5,
"author_filter": "Charles Sanders Peirce"
}
}
}
# Réponse
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"results\": [{\"text\": \"...\", \"similarity\": 0.89}], \"total_count\": 5}"
}
]
}
}
```
## Dépendances Python
```toml
# pyproject.toml
[project]
dependencies = [
"anyio>=4.0.0", # Async I/O
"pydantic>=2.0.0", # Validation
"httpx>=0.27.0", # HTTP client (si download PDF)
# LLM client (choisir un):
"anthropic>=0.39.0", # Pour Claude
"mistralai>=1.2.0", # Pour Mistral
"openai>=1.54.0", # Pour GPT
]
```
## Exemple d'implémentation minimale
### mcp_client.py (squelette)
```python
import asyncio
import json
from typing import Any
from dataclasses import dataclass
@dataclass
class ToolDefinition:
name: str
description: str
input_schema: dict[str, Any]
class MCPClient:
def __init__(self, server_path: str, env: dict[str, str] | None = None):
self.server_path = server_path
self.env = env or {}
self.process = None
self.request_id = 0
async def start(self):
"""Démarrer le MCP server."""
self.process = await asyncio.create_subprocess_exec(
"python", self.server_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **self.env}
)
# Initialize
await self._send_request("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"clientInfo": {"name": "my-app", "version": "1.0"}
})
# Notification initialized
await self._send_notification("notifications/initialized", {})
async def _send_request(self, method: str, params: dict) -> dict:
"""Envoyer une requête JSON-RPC et attendre la réponse."""
self.request_id += 1
request = {
"jsonrpc": "2.0",
"id": self.request_id,
"method": method,
"params": params
}
# Écrire dans stdin
request_json = json.dumps(request) + "\n"
self.process.stdin.write(request_json.encode())
await self.process.stdin.drain()
# Lire depuis stdout
response_line = await self.process.stdout.readline()
response = json.loads(response_line.decode())
return response.get("result")
async def _send_notification(self, method: str, params: dict):
"""Envoyer une notification (pas de réponse attendue)."""
notification = {
"jsonrpc": "2.0",
"method": method,
"params": params
}
notification_json = json.dumps(notification) + "\n"
self.process.stdin.write(notification_json.encode())
await self.process.stdin.drain()
async def list_tools(self) -> list[ToolDefinition]:
"""Obtenir la liste des outils."""
result = await self._send_request("tools/list", {})
tools = result.get("tools", [])
return [
ToolDefinition(
name=tool["name"],
description=tool["description"],
input_schema=tool["inputSchema"]
)
for tool in tools
]
async def call_tool(self, tool_name: str, arguments: dict) -> Any:
"""Appeler un outil."""
result = await self._send_request("tools/call", {
"name": tool_name,
"arguments": arguments
})
# Extraire le contenu texte
content = result.get("content", [])
if content and content[0].get("type") == "text":
return json.loads(content[0]["text"])
return result
async def stop(self):
"""Arrêter le server."""
if self.process:
self.process.terminate()
await self.process.wait()
```
### llm_agent.py (exemple avec Mistral)
```python
from mistralai import Mistral
class LLMAgent:
def __init__(self, mcp_client: MCPClient):
self.mcp_client = mcp_client
self.mistral = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))
self.tools = None
self.messages = []
async def initialize(self):
"""Charger les outils MCP."""
mcp_tools = await self.mcp_client.list_tools()
# Convertir au format Mistral
self.tools = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema
}
}
for tool in mcp_tools
]
async def chat(self, user_message: str) -> str:
"""Converser avec tool calling."""
self.messages.append({
"role": "user",
"content": user_message
})
max_iterations = 10
for _ in range(max_iterations):
# Appel LLM
response = self.mistral.chat.complete(
model="mistral-large-latest",
messages=self.messages,
tools=self.tools,
tool_choice="auto"
)
assistant_message = response.choices[0].message
self.messages.append(assistant_message)
# Si pas de tool calls → réponse finale
if not assistant_message.tool_calls:
return assistant_message.content
# Exécuter les tool calls
for tool_call in assistant_message.tool_calls:
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# Appeler via MCP
result = await self.mcp_client.call_tool(tool_name, arguments)
# Ajouter le résultat
self.messages.append({
"role": "tool",
"name": tool_name,
"content": json.dumps(result),
"tool_call_id": tool_call.id
})
return "Max iterations atteintes"
```
### main.py (exemple d'utilisation)
```python
import asyncio
import os
async def main():
# 1. Créer le client MCP
mcp_client = MCPClient(
server_path="path/to/library_rag/mcp_server.py",
env={
"MISTRAL_API_KEY": os.getenv("MISTRAL_API_KEY"),
"LINEAR_API_KEY": os.getenv("LINEAR_API_KEY") # Si besoin
}
)
# 2. Démarrer le server
await mcp_client.start()
try:
# 3. Créer l'agent LLM
agent = LLMAgent(mcp_client)
await agent.initialize()
# 4. Converser
response = await agent.chat(
"What did Peirce say about nominalism versus realism? "
"Search the database and summarize the key points."
)
print(response)
finally:
# 5. Arrêter le server
await mcp_client.stop()
if __name__ == "__main__":
asyncio.run(main())
```
## Flow complet
```
User: "What did Peirce say about nominalism?"
LLM Agent
├─ Appel Mistral avec tools disponibles
Mistral décide: "Je dois utiliser search_chunks"
LLM Agent → MCP Client
├─ call_tool("search_chunks", {
│ "query": "Peirce nominalism realism",
│ "limit": 10
│ })
MCP Server (subprocess)
├─ Exécute search_chunks_handler
├─ Query Weaviate
├─ Retourne résultats JSON
MCP Client reçoit résultat
LLM Agent renvoie résultat à Mistral
Mistral synthétise la réponse finale
User reçoit: "Peirce was a realist who believed that universals..."
```
## Variables d'environnement requises
```bash
# .env
MISTRAL_API_KEY=your_mistral_key # Pour le LLM ET pour l'OCR
WEAVIATE_URL=http://localhost:8080 # Optionnel (défaut: localhost)
PYTHONPATH=/path/to/library_rag # Pour les imports
```
## Références
- **MCP Protocol**: https://spec.modelcontextprotocol.io/
- **JSON-RPC 2.0**: https://www.jsonrpc.org/specification
- **Mistral Tool Use**: https://docs.mistral.ai/capabilities/function_calling/
- **Anthropic Tool Use**: https://docs.anthropic.com/en/docs/tool-use
## Next Steps
1. Implémenter `MCPClient` avec gestion complète du protocole
2. Implémenter `LLMAgent` avec votre LLM de choix
3. Tester avec un outil simple (`search_chunks`)
4. Ajouter error handling et retry logic
5. Implémenter logging pour debug
6. Ajouter tests unitaires
## Notes importantes
- Le MCP server utilise **stdio** (stdin/stdout) pour la communication
- Chaque message JSON-RPC doit être sur **une seule ligne** terminée par `\n`
- Le server peut envoyer des logs sur **stderr** (à ne pas confondre avec stdout)
- Les tool calls peuvent être **longs** (parse_pdf prend plusieurs minutes)
- Implémenter des **timeouts** appropriés

View File

@@ -1,386 +0,0 @@
# Schéma Weaviate v2 - Justification des Choix de Conception
## Vue d'ensemble
Le schéma v2 corrige les problèmes majeurs du schéma v1 et optimise la base pour:
- **Performance** (vectorisation ciblée)
- **Intégrité** (normalisation, pas de duplication)
- **Évolutivité** (références croisées)
- **Efficacité** (requêtes optimisées)
---
## Comparaison v1 vs v2
### Schéma v1 (Problématique)
```
Work (0 objets) Document (auto-schema)
├── title ├── author ❌ dupliqué
├── author ├── title ❌ dupliqué
├── year └── toc (vide)
└── ... (inutilisé)
Passage (50 objets)
├── chunk ✓
├── author ❌ dupliqué 50×
├── work ❌ dupliqué 50×
└── ... (propriétés auto-ajoutées)
```
**Problèmes**:
- ❌ Work inutilisée (0 objets)
- ❌ author/work dupliqués 50 fois dans Passage
- ❌ Pas de références croisées
- ❌ Auto-schema incontrôlé
### Schéma v2 (Optimisé)
```
Work (source unique)
├── title
├── author
└── year
├──> Document (référence nested)
│ ├── sourceId
│ ├── edition
│ ├── work → {title, author} ✓
│ └── toc
└──> Passage (référence nested)
├── chunk (vectorisé)
├── work → {title, author} ✓
├── document → {sourceId, edition} ✓
└── keywords (vectorisé)
```
**Avantages**:
- ✅ Work est la source unique de vérité
- ✅ Pas de duplication (références nested)
- ✅ Schéma strict (pas d'auto-ajout)
- ✅ Vectorisation contrôlée
---
## Principes de Conception
### 1. Normalisation avec Dénormalisation Partielle
**Principe**: Normaliser les données, mais dénormaliser partiellement via **nested objects** pour la performance.
#### Pourquoi Nested Objects et pas References?
**Option A: True References** (non utilisée)
```python
# Nécessite une requête supplémentaire pour récupérer Work
wvc.Property(
name="work_ref",
data_type=wvc.DataType.REFERENCE,
references="Work"
)
```
❌ Requiert JOIN → 2 requêtes au lieu de 1
**Option B: Nested Objects** (utilisée ✓)
```python
# Work essentiel embarqué dans Passage
wvc.Property(
name="work",
data_type=wvc.DataType.OBJECT,
nested_properties=[
wvc.Property(name="title", data_type=wvc.DataType.TEXT),
wvc.Property(name="author", data_type=wvc.DataType.TEXT),
],
)
```
✅ Une seule requête, données essentielles embarquées
**Compromis accepté**:
- Duplication de `work.title` et `work.author` dans chaque Passage
- **MAIS** contrôlée et minimale (2 champs vs 10+ en v1)
- **GAIN**: 1 requête au lieu de 2, performance 50% meilleure
---
### 2. Vectorisation Sélective
**Principe**: Seuls les champs pertinents pour la recherche sémantique sont vectorisés.
| Collection | Vectorizer | Champs Vectorisés | Pourquoi |
|------------|-----------|-------------------|----------|
| **Work** | NONE | Aucun | Métadonnées uniquement, pas de recherche sémantique |
| **Document** | NONE | Aucun | Métadonnées uniquement |
| **Passage** | text2vec | `chunk`, `keywords` | Recherche sémantique principale |
| **Section** | text2vec | `summary` | Résumés pour vue d'ensemble |
**Impact Performance**:
- v1: ~12 champs vectorisés par Passage (dont author, work, section...)
- v2: 2 champs vectorisés (`chunk` + `keywords`)
- **Gain**: 6× moins de calculs de vectorisation
---
### 3. Skip Vectorization Explicite
**Principe**: Marquer explicitement les champs non vectorisables pour éviter l'auto-vectorisation.
```python
wvc.Property(
name="sectionPath",
data_type=wvc.DataType.TEXT,
skip_vectorization=True, # ← Explicite
)
```
**Champs avec skip_vectorization**:
- `sectionPath` → Pour filtrage exact, pas sémantique
- `chapterTitle` → Pour affichage, pas recherche
- `unitType` → Catégorie, pas sémantique
- `language` → Métadonnée, pas sémantique
- `document.sourceId` → Identifiant technique
- `work.author` → Nom propre (filtrage exact)
**Pourquoi?**
- Vectoriser "Platon" n'a pas de sens sémantique
- Filtrer par `author == "Platon"` est plus rapide avec index
---
### 4. Types de Données Stricts
**Principe**: Utiliser les types Weaviate corrects pour éviter les conversions implicites.
| v1 (Auto-Schema) | v2 (Strict) | Impact |
|------------------|-------------|--------|
| `pages: NUMBER` | `pages: INT` | Validation + index optimisé |
| `createdAt: TEXT` | `createdAt: DATE` | Requêtes temporelles natives |
| `chunksCount: NUMBER` | `passagesCount: INT` | Agrégations efficaces |
**Exemple concret**:
```python
# v1 (auto-schema): pages stocké comme 0.0 (float)
"pages": 0.0 # ❌ Perte de précision, type incorrect
# v2 (strict): pages comme INT
"pages": 42 # ✓ Type correct, validation
```
---
### 5. Hiérarchie des Collections
**Principe**: Ordre de dépendance strict pour les références.
```
1. Work (indépendant)
2. Document (référence Work)
3. Passage (référence Document + Work)
4. Section (référence Document, optionnel)
```
**Lors de l'ingestion**:
1. Créer/récupérer Work
2. Créer Document avec `work: {title, author}`
3. Créer Passages avec `document: {...}` et `work: {...}`
4. (Optionnel) Créer Sections
---
## Requêtes Optimisées
### Recherche Sémantique Simple
```python
# Rechercher "la vertu" dans les passages
passages.query.near_text(
query="la vertu",
limit=10,
return_properties=["chunk", "work.title", "work.author", "sectionPath"]
)
```
**Avantage v2**:
- Une seule requête retourne tout (work nested)
- Pas besoin de JOIN avec Work
### Filtrage par Auteur
```python
# Trouver passages de Platon sur la justice
passages.query.near_text(
query="justice",
filters=wvq.Filter.by_property("work.author").equal("Platon"),
limit=10
)
```
**Avantage v2**:
- Index sur `work.author` (skip_vectorization)
- Filtrage exact rapide
### Navigation Hiérarchique
```python
# Trouver tous les passages d'un chapitre
passages.query.fetch_objects(
filters=wvq.Filter.by_property("chapterTitle").equal("La vertu s'enseigne-t-elle?"),
limit=100
)
```
**Avantage v2**:
- `chapterTitle` indexé (skip_vectorization)
- Pas de vectorisation inutile
---
## Gestion des Cas d'Usage
### Cas 1: Ajouter un nouveau document
```python
# 1. Créer/récupérer Work (une seule fois)
work_data = {"title": "Ménon", "author": "Platon", "year": -380}
# 2. Créer Document
doc_data = {
"sourceId": "menon_cousin_1850",
"edition": "trad. Cousin",
"work": {"title": "Ménon", "author": "Platon"}, # Nested
"pages": 42,
"passagesCount": 50,
}
# 3. Créer Passages
passage_data = {
"chunk": "...",
"work": {"title": "Ménon", "author": "Platon"}, # Nested
"document": {"sourceId": "menon_cousin_1850", "edition": "trad. Cousin"},
...
}
```
### Cas 2: Supprimer un document
```python
# Supprimer tous les objets liés
delete_passages(sourceId="menon_cousin_1850")
delete_sections(sourceId="menon_cousin_1850")
delete_document(sourceId="menon_cousin_1850")
# Work reste (peut être utilisé par d'autres Documents)
```
### Cas 3: Recherche multi-éditions
```python
# Comparer deux traductions du Ménon
passages.query.near_text(
query="réminiscence",
filters=wvq.Filter.by_property("work.title").equal("Ménon"),
)
# Retourne passages de toutes les éditions
```
---
## Migration v1 → v2
### Étape 1: Sauvegarder les données v1
```bash
python toutweaviate.py # Export complet
```
### Étape 2: Recréer le schéma v2
```bash
python schema_v2.py
```
### Étape 3: Adapter le code d'ingestion
Modifier `weaviate_ingest.py`:
```python
# AVANT (v1):
passage_obj = {
"chunk": text,
"work": title, # ❌ STRING dupliqué
"author": author, # ❌ STRING dupliqué
...
}
# APRÈS (v2):
passage_obj = {
"chunk": text,
"work": { # ✓ OBJECT nested
"title": title,
"author": author,
},
"document": { # ✓ OBJECT nested
"sourceId": doc_name,
"edition": edition,
},
...
}
```
### Étape 4: Ré-ingérer les données
```bash
# Traiter à nouveau le PDF avec le nouveau schéma
python flask_app.py
# Upload via interface
```
---
## Métriques de Performance
### Taille des Données
| Métrique | v1 | v2 | Gain |
|----------|----|----|------|
| Duplication author/work | 50× | 1× (Work) + 50× nested (contrôlé) | 30% espace |
| Propriétés auto-ajoutées | 12 | 0 | 100% contrôle |
| Champs vectorisés | ~8 | 2 | 75% calculs |
### Requêtes
| Opération | v1 | v2 | Gain |
|-----------|----|----|------|
| Recherche + métadonnées | 2 requêtes (Passage + JOIN) | 1 requête (nested) | 50% latence |
| Filtrage par auteur | Scan vectoriel | Index exact | 10× vitesse |
| Navigation hiérarchique | N/A (pas de Section) | Index + nested | ∞ |
---
## Conclusion
### Choix Clés du Schéma v2
1.**Nested Objects** pour performance (1 requête au lieu de 2)
2.**Skip Vectorization** sur métadonnées (performance, filtrage exact)
3.**Types Stricts** (INT, DATE, TEXT, OBJECT)
4.**Vectorisation Sélective** (chunk + keywords uniquement)
5.**Work comme Source Unique** (pas de duplication)
### Compromis Acceptés
1. ⚠️ Légère duplication via nested objects (acceptable)
2. ⚠️ Pas de true references (pour performance)
3. ⚠️ Section optionnelle (pour simplicité)
### Prochaines Étapes
1. Tester `schema_v2.py`
2. Adapter `weaviate_ingest.py` pour nested objects
3. Migrer les données existantes
4. Valider les requêtes
---
**Schéma v2 = Production-Ready ✓**

View File

@@ -1,113 +0,0 @@
# BGE-M3 Search Quality Validation Results
**Generated:** (Run `python test_bge_m3_quality.py --output SEARCH_QUALITY_RESULTS.md` to populate)
**Weaviate Version:** TBD
## Database Statistics
- **Total Documents:** TBD
- **Total Chunks:** TBD
- **Vector Dimensions:** TBD (expected: 1024)
## Vector Dimension Verification
Run the validation script to confirm BGE-M3 (1024-dim) vectors are properly configured.
Expected output: **BGE-M3 (1024-dim) vectors confirmed.**
## Test Categories
### 1. Multilingual Queries
Tests the model's ability to understand philosophical terms in multiple languages:
| Language | Test Terms |
|----------|------------|
| French | justice, vertu, liberte, verite, connaissance |
| English | virtue, knowledge, ethics, wisdom, justice |
| Greek | arete, telos, psyche, logos, eudaimonia |
| Latin | virtus, sapientia, forma, anima, ratio |
### 2. Semantic Understanding
Tests concept mapping for philosophical questions:
| Query | Expected Topics |
|-------|----------------|
| "What is the nature of reality?" | ontology, metaphysics, being |
| "How should we live?" | ethics, virtue, good life |
| "What can we know?" | epistemology, knowledge, truth |
| "What is the meaning of life?" | purpose, existence, value |
| "What is beauty?" | aesthetics, art, form |
### 3. Long Query Handling
Tests the extended 8192 token context (vs MiniLM-L6's 512 tokens):
- Uses a 100+ word query about Plato's Meno
- Verifies no truncation occurs
- Measures semantic accuracy of results
### 4. Performance Metrics
Performance targets:
- **Query Latency:** < 500ms average
- **Throughput:** Measured across 10 iterations per query
## Running the Tests
```bash
# Run all tests with verbose output
python test_bge_m3_quality.py --verbose
# Generate markdown report
python test_bge_m3_quality.py --output SEARCH_QUALITY_RESULTS.md
# Output as JSON
python test_bge_m3_quality.py --json
```
## Prerequisites
1. Weaviate must be running:
```bash
docker-compose up -d
```
2. Documents must be ingested with BGE-M3 vectorizer
3. Schema must be created with 1024-dim vectors
## Expected Improvements over MiniLM-L6
| Feature | MiniLM-L6 | BGE-M3 |
|---------|-----------|--------|
| Vector Dimensions | 384 | 1024 (2.7x richer) |
| Context Window | 512 tokens | 8192 tokens (16x larger) |
| Multilingual | Limited | Excellent (Greek, Latin, French, English) |
| Academic Texts | Good | Superior (trained on research papers) |
## Troubleshooting
### "Connection error: Failed to connect to Weaviate"
Ensure Weaviate is running:
```bash
docker-compose up -d
docker-compose ps # Check status
```
### "No vectors found in Chunk collection"
Ensure documents have been ingested:
```bash
python reingest_from_cache.py
```
### Vector dimensions show 384 instead of 1024
The BGE-M3 migration is incomplete. Re-run:
```bash
python migrate_to_bge_m3.py
```

View File

@@ -1,196 +0,0 @@
# 📑 Extraction de la Table des Matières (TOC)
## Vue d'ensemble
Le système Philosophia propose **deux méthodes** pour extraire la table des matières des documents PDF :
1. **Extraction LLM classique** (par défaut) - Analyse sémantique via modèle de langage
2. **Extraction avec analyse d'indentation** (recommandé) - Détection visuelle de la hiérarchie
## 🎯 Méthode recommandée : Analyse d'indentation
### Fonctionnement
Cette méthode analyse le **markdown généré par l'OCR** pour détecter la hiérarchie en comptant les espaces d'indentation :
```
Présentation → 0-2 espaces = niveau 1
Qu'est-ce que la vertu ? → 3-6 espaces = niveau 2
Modèles de définition → 3-6 espaces = niveau 2
Ménon ou de la vertu → 0-2 espaces = niveau 1
```
### Avantages
-**Fiable** : Détection basée sur la position réelle du texte
-**Rapide** : Pas d'appel API supplémentaire
-**Économique** : Coût zéro (utilise l'OCR déjà effectué)
-**Hiérarchique** : Construit correctement la structure parent/enfant
### Activation
Dans l'interface Flask, cochez **"Extraction TOC améliorée (analyse indentation)"** lors de l'upload :
```python
# Via API
process_pdf(
pdf_path,
use_ocr_annotations=True, # Active l'analyse d'indentation
)
```
### Algorithme
1. **Détection de la TOC** : Recherche "Table des matières" dans le markdown
2. **Extraction des entrées** : Pattern regex `Titre.....PageNumber`
3. **Comptage des espaces** :
- `0-2 espaces` → niveau 1 (titre principal)
- `3-6 espaces` → niveau 2 (sous-section)
- `7+ espaces` → niveau 3 (sous-sous-section)
4. **Construction hiérarchique** : Utilisation d'une stack pour organiser parent/enfant
### Code source
- **Module principal** : `utils/toc_extractor_markdown.py`
- **Intégration pipeline** : `utils/pdf_pipeline.py` (ligne ~290)
- **Fonction clé** : `extract_toc_from_markdown()`
## 📊 Méthode alternative : Extraction LLM
### Fonctionnement
Envoie le markdown complet à un LLM (Mistral ou Ollama) qui analyse sémantiquement la structure.
### Avantages
- Comprend la structure logique même sans indentation claire
- Peut déduire la hiérarchie du contexte
### Inconvénients
-**Moins fiable** : Peut mal interpréter la structure
-**Plus lent** : Appel LLM supplémentaire
-**Plus cher** : Consomme des tokens
-**Aplatit parfois** : Tendance à mettre tout au même niveau
### Activation
C'est la méthode par défaut si l'option "Extraction TOC améliorée" n'est **pas** cochée.
## 🔧 Configuration avancée
### Paramètres personnalisables
```python
# Dans toc_extractor_markdown.py
def extract_toc_from_markdown(
markdown_text: str,
max_lines: int = 200, # Lignes à analyser pour trouver la TOC
):
# Seuils d'indentation personnalisables
if leading_spaces <= 2:
level = 1 # Modifier selon votre format
elif leading_spaces <= 6:
level = 2
else:
level = 3
```
### Pattern TOC personnalisable
Le pattern regex détecte les formats suivants :
- `Titre.....3` (avec points de suite)
- `Titre 3` (avec espaces)
- `Titre..3` (avec quelques points)
Pour modifier, éditer la regex dans `toc_extractor_markdown.py` :
```python
match = re.match(r'^(.+?)\s*\.{2,}\s*(\d+)\s*$', line)
```
## 📈 Résultats comparatifs
### Document test : Ménon de Platon (107 pages)
| Méthode | Entrées | Niveaux | Hiérarchie | Temps | Coût |
|---------|---------|---------|------------|-------|------|
| **LLM classique** | 11 | Tous level 1 | ❌ Plate | ~15s | +0.002€ |
| **Analyse indentation** | 11 | 2 niveaux | ✅ Correcte | <1s | 0€ |
### Exemple de structure obtenue
```json
{
"title": "Présentation",
"level": 1,
"children": [
{"title": "Qu'est-ce que la vertu ?", "level": 2},
{"title": "Modèles de définition", "level": 2},
{"title": "Définition de la vertu", "level": 2},
...
]
},
{
"title": "Ménon ou de la vertu",
"level": 1,
"children": []
}
```
## 🐛 Dépannage
### La TOC n'est pas détectée
**Problème** : Le message "Table des matières introuvable" apparaît
**Solutions** :
1. Vérifier que le PDF contient bien une TOC explicite
2. Augmenter `max_lines` si la TOC est très loin dans le document
3. Vérifier que la TOC contient le texte "Table des matières" ou variantes
### Tous les titres sont au level 1
**Problème** : Aucune hiérarchie détectée
**Solutions** :
1. Vérifier que les titres ont une **indentation visuelle** dans le PDF original
2. Ajuster les seuils d'espaces dans le code (lignes ~90-95 de `toc_extractor_markdown.py`)
3. Examiner le fichier `.md` pour voir comment l'OCR a préservé l'indentation
### Entrées manquantes
**Problème** : Certains titres n'apparaissent pas
**Solutions** :
1. Vérifier le pattern regex (peut ne pas correspondre au format de votre TOC)
2. Regarder les logs : `logger.debug()` affiche chaque ligne analysée
3. Augmenter la limite de lignes analysées
## 🔬 Mode debug
Pour activer les logs détaillés :
```python
import logging
logging.getLogger('utils.toc_extractor_markdown').setLevel(logging.DEBUG)
```
Vous verrez :
```
Extraction TOC depuis markdown (analyse indentation)
TOC trouvée à la ligne 42
'Présentation' → 0 espaces → level 1 (page 3)
'Qu'est-ce que la vertu ?' → 4 espaces → level 2 (page 3)
...
✅ 11 entrées extraites depuis markdown
```
## 📚 Références
- **Code source** : `utils/toc_extractor_markdown.py`
- **Tests** : Testé sur Platon - Ménon, Tiercelin - La pensée-signe
- **Format supporté** : PDF avec TOC textuelle indentée
- **Langues** : Français, fonctionne avec toute langue utilisant des espaces

View File

@@ -1,267 +0,0 @@
# Pipeline d'Extraction de TOC Hiérarchisée (utils2/) - Documentation Complète
**Date**: 2025-12-09
**Version**: 1.0.0
**Statut**: ✅ **Implémentation Complète et Testée**
---
## 📋 Résumé Exécutif
Pipeline simplifié dans `utils2/` pour extraire la table des matières (TOC) de PDFs avec hiérarchie précise via analyse de bounding boxes. **91 tests unitaires** valident l'implémentation (100% de réussite).
### Caractéristiques Principales
-**Détection automatique multilingue** (FR, EN, ES, DE, IT)
-**Hiérarchie précise** via positions X (bounding boxes)
-**Pipeline 2-passes optimisé** (économie de 65% des coûts)
-**Support multi-pages** (TOC s'étalant sur plusieurs pages)
-**Sortie double** : Markdown console + JSON structuré
-**CLI simple** : `python recherche_toc.py fichier.pdf`
---
## 🎯 Problème Résolu : Ménon de Platon
### Avant (OCR Simple)
```
TOC détectée ✓
Titres extraits ✓
Hiérarchie ❌ → Tout au niveau 1 (indentation perdue en OCR)
```
**Résultat** : Structure plate, hiérarchie visuelle perdue.
### Après (Bounding Boxes)
```
TOC détectée ✓
Bbox récupérés ✓ (x, y de chaque ligne)
Position X analysée ✓
Hiérarchie ✓ → Niveaux 1, 2, 3 corrects
```
**Résultat** : Hiérarchie précise préservée.
---
## 🏗️ Architecture
### Pipeline en 2 Passes
```
┌─────────────────────────────────────────────────────────────┐
│ PASSE 1 : Détection Rapide (OCR Simple) │
│ • Coût : 0.001€/page │
│ • Scanne tout le document │
│ • Détecte les pages contenant la TOC │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PASSE 2 : Extraction Précise (OCR avec Bounding Boxes) │
│ • Coût : 0.003€/page (uniquement sur pages TOC) │
│ • Récupère positions X, Y de chaque ligne │
│ • Calcule le niveau hiérarchique depuis position X │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Construction Hiérarchique + Sortie │
│ • Structure parent-enfant │
│ • Markdown console │
│ • JSON structuré │
└─────────────────────────────────────────────────────────────┘
```
### Détection de Hiérarchie
**Principe Clé** : Position X → Niveau hiérarchique
```python
x = 100px Niveau 1 (pas d'indentation)
x = 130px Niveau 2 (indenté de 30px)
x = 160px Niveau 3 (indenté de 60px)
x = 190px Niveau 4 (indenté de 90px)
x = 220px Niveau 5 (indenté de 120px)
```
**Tolérance** : ±10px pour variations d'alignement
---
## 📁 Fichiers Créés
### Modules Core (`utils2/`)
| Fichier | Lignes | Description |
|---------|--------|-------------|
| `pdf_uploader.py` | 35 | Upload PDF vers Mistral API |
| `ocr_schemas.py` | 31 | Schémas Pydantic (OCRPage, OCRResponse, TOCBoundingBox) |
| `toc.py` | 420 | ⭐ Logique d'extraction et hiérarchisation |
| `recherche_toc.py` | 181 | 🚀 Script CLI principal (6 étapes) |
| `README.md` | 287 | Documentation complète |
**Total** : 954 lignes de code
### Tests (`tests/utils2/`)
| Fichier | Tests | Description |
|---------|-------|-------------|
| `test_toc.py` | 40 | Tests extraction, parsing, hiérarchie |
| `test_ocr_schemas.py` | 23 | Tests validation Pydantic |
| `test_mistral_client.py` | 28 | Tests configuration, coûts |
**Total** : 91 tests (100% réussite)
---
## 💰 Coûts et Optimisation
### Tarification Mistral OCR
| Type | Coût | Usage |
|------|------|-------|
| OCR simple | 0.001€/page | Passe 1 (détection) |
| OCR avec bbox | 0.003€/page | Passe 2 (extraction) |
### Exemples Réels
**Document 50 pages, TOC sur 3 pages :**
```
Passe 1: 50 × 0.001€ = 0.050€
Passe 2: 3 × 0.003€ = 0.009€
─────────────────────────────
Total: 0.059€
```
**Document 200 pages, TOC sur 5 pages :**
```
Passe 1: 200 × 0.001€ = 0.200€
Passe 2: 5 × 0.003€ = 0.015€
─────────────────────────────
Total: 0.215€
```
### Économies vs Approche Naïve
**Approche naïve** : OCR bbox sur toutes les pages
```
200 pages × 0.003€ = 0.600€
```
**Pipeline 2-passes** : OCR simple + bbox ciblé
```
0.215€
```
**💰 Économie : 64%**
---
## 🚀 Usage
### Installation
```bash
pip install mistralai python-dotenv pydantic
```
### Configuration
```bash
# .env à la racine
MISTRAL_API_KEY=votre_clé_api
```
### Commandes
**Extraction simple :**
```bash
python utils2/recherche_toc.py document.pdf
```
**Avec options :**
```bash
# Spécifier sortie JSON
python utils2/recherche_toc.py document.pdf --output ma_toc.json
# Affichage uniquement (pas de JSON)
python utils2/recherche_toc.py document.pdf --no-json
# Clé API explicite
python utils2/recherche_toc.py document.pdf --api-key sk-xxx
```
---
## 🧪 Tests et Validation
### Statistiques
- **91 tests unitaires** (100% réussite)
- **Temps d'exécution** : ~2.76 secondes
- **Couverture** : Fonctions core, schémas, coûts, edge cases
### Commandes de Test
```bash
# Tous les tests
python -m pytest tests/utils2/ -v
# Test rapide
python -m pytest tests/utils2/ -q
# Tests spécifiques
python -m pytest tests/utils2/test_toc.py -v
```
---
## ✅ Critères de Succès (Tous Atteints)
- [x] OCR Mistral fonctionne dans utils2/
- [x] Pipeline 2-passes implémenté
- [x] Bounding boxes récupérés
- [x] **Hiérarchie détectée via position X** ← CRITIQUE
- [x] Détection TOC multilingue (FR, EN, ES, DE, IT)
- [x] Support TOC multi-pages
- [x] CLI fonctionnel
- [x] Documentation complète
- [x] Tests passants (91 tests, 100%)
- [x] Coût optimisé (< 0.10€ pour 50 pages)
---
## 📊 Métriques Finales
| Métrique | Valeur |
|----------|--------|
| **Fichiers créés** | 10 (5 modules + 3 tests + 2 docs) |
| **Lignes de code** | 954 (modules) + 800 (tests) |
| **Tests unitaires** | 91 tests |
| **Taux de réussite** | 100% |
| **Temps tests** | 2.76s |
| **Économie coûts** | 65% |
| **Langues** | 5 |
---
## 🎉 Conclusion
Le pipeline d'extraction de TOC dans `utils2/` est **complet, testé et prêt pour production**.
**Points Forts** :
- ✅ Architecture 2-passes optimisée (65% d'économie)
- ✅ Hiérarchie précise via positions X
- ✅ 91 tests validant tous les cas d'usage
- ✅ Documentation complète
**Statut** : ✅ Production Ready
---
**Auteur** : Pipeline utils2 - TOC Extraction
**Date** : 2025-12-09
**Version** : 1.0.0

View File

@@ -1,465 +0,0 @@
# Analyse de Cohérence des Collections Weaviate
**Date**: 2025-12-09
**Analysé**: 3 collections, 51 objets
---
## Résumé Exécutif
### Problèmes Critiques Identifiés
1. **Désynchronisation schéma défini vs schéma réel** - Le schéma dans `schema.py` ne correspond PAS au schéma actuel dans Weaviate
2. **Collection Section manquante** - Définie dans `schema.py` mais inexistante dans Weaviate
3. **Collection Work inutilisée** - 0 objets, redondante avec les autres collections
4. **Duplication massive de données** - author/work répétés 50 fois au lieu d'utiliser des références
5. **Métadonnées vides** - TOC et hiérarchie non exploitées
6. **Auto-schema non contrôlé** - Propriétés ajoutées automatiquement sans validation
---
## 1. Collection Document
### Configuration Actuelle
- **Vectorizer**: `TEXT2VEC_TRANSFORMERS` ⚠️
- **Objets**: 1
- **Auto-generated**: OUI (toutes les propriétés)
### ❌ Problèmes Identifiés
#### 1.1 Schéma Auto-Généré
```
"This property was generated by Weaviate's auto-schema feature on Fri Dec 5 16:10:30 2025"
```
- Le schéma réel n'a **PAS été créé** via `schema.py`
- Weaviate a auto-généré le schéma lors de l'insertion
- **Conséquence**: Perte de contrôle sur les types et la configuration
#### 1.2 Vectorizer Incorrect
**Attendu** (schema.py:21):
```python
vectorizer_config=wvc.Configure.Vectorizer.none()
```
**Réel**:
```
Vectorizer: TEXT2VEC_TRANSFORMERS
```
**Impact**: Vectorisation inutile des métadonnées → gaspillage de ressources
#### 1.3 Skip Vectorization Ignoré
**Attendu** (schema.py:85-86):
```python
skip_vectorization=True # Pour sectionPath et title
```
**Réel**:
```
Toutes les propriétés: Skip Vectorization = ❌
```
**Impact**: Toutes les métadonnées sont vectorisées inutilement
#### 1.4 Données Vides/Invalides
```json
{
"toc": "[]", // ❌ Vide alors que le document a une TOC
"hierarchy": "{}", // ❌ Vide alors que le document a une hiérarchie
"pages": 0.0, // ❌ Devrait être > 0
"chunksCount": 50.0 // ⚠️ Float au lieu de INT
}
```
#### 1.5 Type DATE Perdu
**Attendu** (schema.py:66):
```python
data_type=wvc.DataType.DATE
```
**Réel**:
```
createdAt: TEXT
```
**Impact**: Impossible de filtrer par date efficacement
---
## 2. Collection Passage
### Configuration Actuelle
- **Vectorizer**: `TEXT2VEC_TRANSFORMERS`
- **Objets**: 50
- **Description**: Correcte
### ⚠️ Problèmes Identifiés
#### 2.1 Propriétés Non-Définies Ajoutées
Le schéma dans `schema.py` définit 9 propriétés, mais Weaviate en a **12**:
**Propriétés supplémentaires auto-générées**:
- `chapterTitle` (TEXT)
- `chapterConcepts` (TEXT_ARRAY)
- `sectionLevel` (NUMBER)
**Problème**: Ces propriétés ne sont pas dans le schéma original et ont été ajoutées automatiquement sans validation.
#### 2.2 Skip Vectorization Non Respecté
Selon `schema.py`, AUCUNE propriété de Passage ne devrait avoir `skip_vectorization=True`.
**Réel**: Toutes les propriétés sont vectorisées ✅ (correct)
#### 2.3 Duplication Massive de Données
**author** répété 50 fois:
```json
"author": "Platon" // x50 passages
```
**work** répété 50 fois:
```json
"work": "Ménon ou de la vertu" // x50 passages
```
**Impact**:
- Gaspillage d'espace (50 × ~20 octets = 1 Ko juste pour author)
- Pas de normalisation
- Impossible de changer l'auteur globalement
- Pas de relation avec la collection Work
#### 2.4 Données Incohérentes
**orderIndex**:
- Min: 1, Max: 49 (attendu: 0-49 pour 50 chunks)
- ⚠️ Manque l'index 0 OU l'index 50
**keywords**:
- Parfois vide `[]` (11 passages)
- Pas de normalisation
**chapterConcepts**:
- **TOUJOURS vide** `[]` pour tous les passages
- Feature non utilisée → propriété inutile
**unitType**:
- 5 valeurs: `exposition`, `main_content`, `argument`, `transition`, `définition`
- Pas de validation (pourrait contenir n'importe quoi)
**section**:
- 13 valeurs uniques pour 50 passages
- Très variable: `"SOCRATE"`, `"MENON"`, `"Qu'est-ce que la vertu?"`, etc.
- Pas de format standard
---
## 3. Collection Work
### Configuration Actuelle
- **Vectorizer**: `NONE`
- **Objets**: **0**
- **Schéma**: Correct
### 🚨 Problèmes Critiques
#### 3.1 Collection Complètement Inutilisée
```
Nombre d'objets: 0
```
**Pourquoi existe-t-elle?**
- Définie dans `schema.py`
- Jamais utilisée par `weaviate_ingest.py`
#### 3.2 Redondance Totale
Les informations de Work sont **dupliquées** dans:
1. **Document.author** + **Document.title**
2. **Passage.author** + **Passage.work** (x50)
**Solution attendue**: Utiliser Work comme source unique avec des références croisées.
#### 3.3 Propriétés Inutiles
```python
year: INT # Jamais renseigné
edition: TEXT # Jamais renseigné
referenceSystem: TEXT # Jamais renseigné
```
---
## 4. Collection Section (Manquante!)
### 🚨 Définie mais Inexistante
**Dans schema.py** (lignes 74-120):
```python
client.collections.create(
name="Section",
description="A section/chapter with its summary and key concepts...",
...
)
```
**Dans Weaviate**:
```
Collections: Document, Passage, Work
```
**Section est ABSENTE!**
### Impact
- Impossible de faire des résumés de chapitres vectorisés
- Perte de la hiérarchie structurée
- Feature complète non implémentée
---
## 5. Problèmes de Conception Architecturale
### 5.1 Absence de Relations Croisées
**Attendu** (architecture normalisée):
```
Work (1) ──< Document (N) ──< Passage (N)
└──< Section (N) ──< Passage (N)
```
**Réel**:
```
Document (1) [pas de lien]
Passage (50) [pas de lien]
Work (0) [vide]
Section [manquant]
```
**Conséquence**: Impossible de naviguer entre collections
### 5.2 Pas de Cross-References
Weaviate v4 supporte les références croisées, mais elles ne sont **pas utilisées**:
```python
# Ce qu'on devrait avoir dans Passage:
wvc.Property(
name="document",
data_type=wvc.DataType.REFERENCE,
references="Document"
)
```
### 5.3 Duplication vs Normalisation
**Taille actuelle (estimée)**:
- Document: 1 × ~500 octets = 500 B
- Passage: 50 × ~600 octets = 30 Ko
- **Total dupliqué**: author (50×) + work (50×) ≈ 2 Ko de redondance
**Avec normalisation**:
- Work: 1 objet avec author + title
- Passage: Référence UUID vers Work
- **Économie**: ~1.5 Ko + meilleure intégrité
---
## 6. Analyse des Données
### 6.1 Document "Platon_-_Menon_trad._Cousin"
```json
{
"title": "Ménon ou de la vertu",
"author": "Platon",
"sourceId": "Platon_-_Menon_trad._Cousin",
"language": "fr",
"pages": 0.0, // ❌ Invalide
"chunksCount": 50.0, // ✅ Mais devrait être INT
"toc": "[]", // ❌ Vide
"hierarchy": "{}", // ❌ Vide
"createdAt": "2025-12-09T09:20:30.970580"
}
```
**Problèmes**:
1. `pages: 0` → Le PDF avait forcément des pages
2. `toc: "[]"` → Le système extrait une TOC (voir `llm_toc.py`), pourquoi est-elle vide?
3. `hierarchy: "{}"` → Idem, la hiérarchie devrait être remplie
### 6.2 Distribution des Passages
**Par unitType**:
- main_content: ~25
- argument: ~15
- exposition: ~5
- transition: ~3
- définition: ~2
**Par section (top 5)**:
- "SOCRATE": 8 passages
- "MENON": 7 passages
- "Qu'est-ce que la vertu?": 6 passages
- "Vérification de la réminiscence": 5 passages
- "La vertu s'enseigne-t-elle?": 8 passages
**Par chapterTitle (top 3)**:
- "Ménon ou de la vertu": 7 passages
- "Présentation": 6 passages
- "La vertu s'enseigne-t-elle?": 8 passages
⚠️ **Confusion**: `section` et `chapterTitle` se chevauchent sans logique claire
---
## 7. Écart Schema.py vs Weaviate Réel
| Aspect | schema.py | Weaviate Réel | État |
|--------|-----------|---------------|------|
| **Collections** | 4 (Document, Section, Passage, Work) | 3 (Document, Passage, Work) | ❌ Section manquante |
| **Document.vectorizer** | NONE | TEXT2VEC_TRANSFORMERS | ❌ Incorrect |
| **Document.createdAt** | DATE | TEXT | ❌ Type perdu |
| **Document.skip_vectorization** | Défini | Ignoré | ❌ Non appliqué |
| **Passage propriétés** | 9 | 12 | ⚠️ 3 ajoutées automatiquement |
| **Section** | Définie | Absente | ❌ Non créée |
| **Work objets** | N/A | 0 | ⚠️ Inutilisée |
**Cause probable**: Le schéma n'a **jamais été appliqué** correctement. Les collections ont été créées par auto-schema lors de la première insertion.
---
## 8. Recommandations
### 8.1 Actions Immédiates (Critiques)
1. **Supprimer et recréer le schéma**
```bash
python schema.py # Recréer proprement
```
2. **Vérifier que Section est créée**
- Ajouter des logs dans `schema.py`
- Vérifier avec `client.collections.list_all()`
3. **Réparer les métadonnées du Document**
- Remplir `toc` avec les vraies données
- Remplir `hierarchy` avec la structure
- Corriger `pages` (nombre réel de pages du PDF)
4. **Nettoyer les propriétés orphelines**
- Soit définir `chapterTitle`, `chapterConcepts`, `sectionLevel` dans le schéma
- Soit les supprimer des données
### 8.2 Améliorations Architecturales
1. **Normaliser avec Work**
```python
# Dans Passage, remplacer author/work par:
wvc.Property(
name="work_ref",
data_type=wvc.DataType.REFERENCE,
references="Work"
)
```
2. **Ajouter Document → Passage reference**
```python
wvc.Property(
name="document_ref",
data_type=wvc.DataType.REFERENCE,
references="Document"
)
```
3. **Implémenter Section**
- Créer des objets Section pour chaque chapitre
- Lier Section ← Passage via référence
- Ajouter des résumés LLM aux sections
### 8.3 Validation des Données
1. **Ajouter des contraintes**
- `unitType` → Enum validé
- `orderIndex` → Doit aller de 0 à chunksCount-1
- `pages` > 0
2. **Normaliser keywords**
- Éviter les doublons
- Normaliser la casse
- Supprimer les arrays vides si non utilisés
3. **Standardiser section/chapterTitle**
- Décider d'un format unique
- Séparer titre de chapitre vs nom de locuteur
### 8.4 Pipeline d'Ingestion
**Modifier `weaviate_ingest.py`**:
1. Créer un objet **Work** d'abord
2. Créer un objet **Document** avec référence à Work
3. Créer des objets **Section** avec références
4. Créer des **Passages** avec références vers Document + Section
5. Valider les données avant insertion
---
## 9. Impact Business
### Problèmes Actuels
| Problème | Impact Utilisateur | Gravité |
|----------|-------------------|---------|
| Section manquante | Pas de navigation par chapitre | 🔴 Haute |
| TOC vide | Impossible de voir la structure | 🔴 Haute |
| Work inutilisée | Duplication, pas de filtre par œuvre | 🟡 Moyenne |
| Auto-schema | Schéma imprévisible, bugs futurs | 🔴 Haute |
| orderIndex incorrect | Ordre des passages peut être faux | 🟡 Moyenne |
### Bénéfices de la Correction
1. **Navigation structurée** via Section
2. **Recherche optimisée** avec références croisées
3. **Métadonnées riches** (TOC, hiérarchie)
4. **Intégrité des données** avec schéma strict
5. **Performance** (moins de duplication)
---
## 10. Plan d'Action Proposé
### Phase 1: Diagnostic Complet (1h)
- [ ] Vérifier pourquoi `schema.py` n'a pas été appliqué
- [ ] Examiner les logs d'insertion dans `weaviate_ingest.py`
- [ ] Identifier quand l'auto-schema s'est déclenché
### Phase 2: Correction du Schéma (2h)
- [ ] Supprimer toutes les collections
- [ ] Ré-exécuter `schema.py` avec logs
- [ ] Vérifier que les 4 collections existent avec le bon schéma
- [ ] Tester l'insertion d'un document de test
### Phase 3: Migration des Données (3h)
- [ ] Exporter les 50 passages actuels
- [ ] Créer un objet Work pour "Ménon"
- [ ] Créer un Document avec TOC/hierarchy remplis
- [ ] Créer des Sections par chapitre
- [ ] Ré-insérer les Passages avec références
### Phase 4: Validation (1h)
- [ ] Tester les requêtes avec références
- [ ] Vérifier l'intégrité des données
- [ ] Documenter le nouveau schéma
- [ ] Mettre à jour `README.md`
**Temps total estimé**: ~7 heures
---
## Conclusion
Le système actuel souffre d'une **désynchronisation majeure** entre le schéma défini et la réalité dans Weaviate. Les collections ont été créées par auto-schema au lieu d'utiliser le schéma explicite, ce qui a conduit à:
1. ❌ Perte de contrôle sur les types et la vectorisation
2. ❌ Collection Section complètement absente
3. ❌ Duplication massive de données
4. ❌ Métadonnées vides et invalides
5. ❌ Pas de relations entre collections
**Priorité**: Recréer proprement le schéma et migrer les données pour exploiter tout le potentiel de l'architecture vectorielle.

View File

@@ -1,71 +0,0 @@
# Known Issues - MCP Client
## 1. Author/Work Filters Not Supported (Weaviate Limitation)
**Status:** Known limitation
**Affects:** `search_chunks` and `search_summaries` tools
**Error:** Results in server error when using `author_filter` or `work_filter` parameters
**Root Cause:**
Weaviate v4 does not support filtering on nested object properties. The `work` field in the Chunk schema is defined as:
```python
wvc.Property(
name="work",
data_type=wvc.DataType.OBJECT,
nested_properties=[
wvc.Property(name="title", data_type=wvc.DataType.TEXT),
wvc.Property(name="author", data_type=wvc.DataType.TEXT),
],
)
```
Attempts to filter on `work.author` or `work.title` result in:
```
data type "object" not supported in query
```
**Workaround:**
Use the `filter_by_author` tool instead:
```python
# Instead of:
search_chunks(
query="nominalism",
author_filter="Charles Sanders Peirce" # ❌ Doesn't work
)
# Use:
filter_by_author(
author="Charles Sanders Peirce" # ✓ Works
)
```
Or search without filters and filter client-side:
```python
results = await client.call_tool("search_chunks", {
"query": "nominalism",
"limit": 50 # Fetch more
})
# Filter in Python
filtered = [
r for r in results["results"]
if r["work_author"] == "Charles Sanders Peirce"
]
```
**Future Fix:**
Option 1: Add flat properties `workAuthor` and `workTitle` to Chunk schema (requires migration)
Option 2: Implement post-filtering in Python on the server side
Option 3: Wait for Weaviate to support nested object filtering
**Tests Affected:**
- `test_mcp_client.py::test_search_chunks` - Works without filters
- Search with `author_filter` - Currently fails
**Last Updated:** 2025-12-25

View File

@@ -1,165 +0,0 @@
# Library RAG - Exemples MCP Client
Ce dossier contient des exemples d'implémentation de clients MCP pour utiliser Library RAG depuis votre application Python.
## Clients MCP avec LLM
### 1. `mcp_client_claude.py` ⭐ RECOMMANDÉ
**Client MCP avec Claude (Anthropic)**
**Modèle:** Claude Sonnet 4.5 (`claude-sonnet-4-5-20250929`)
**Features:**
- Auto-chargement des clés depuis `.env`
- Tool calling automatique
- Gestion multi-tour de conversation
- Synthèse naturelle des résultats
**Usage:**
```bash
# Assurez-vous que .env contient:
# ANTHROPIC_API_KEY=your_key
# MISTRAL_API_KEY=your_key
python examples/mcp_client_claude.py
```
**Exemple:**
```
User: "What did Peirce say about nominalism?"
Claude → search_chunks(query="Peirce nominalism")
→ Weaviate (BGE-M3 embeddings)
→ 10 chunks retournés
Claude → "Peirce characterized nominalism as a 'tidal wave'..."
```
### 2. `mcp_client_reference.py`
**Client MCP avec Mistral AI**
**Modèle:** Mistral Large (`mistral-large-latest`)
Même fonctionnalités que le client Claude, mais utilise Mistral AI.
**Usage:**
```bash
python examples/mcp_client_reference.py
```
## Tests
### `test_mcp_quick.py`
Test rapide (< 5 secondes) des fonctionnalités MCP:
- ✅ search_chunks (recherche sémantique)
- ✅ list_documents
- ✅ filter_by_author
```bash
python examples/test_mcp_quick.py
```
### `test_mcp_client.py`
Suite de tests complète pour le client MCP (tests unitaires des 9 outils).
## Exemples sans MCP (direct pipeline)
### `example_python_usage.py`
Utilisation des handlers MCP directement (sans subprocess):
```python
from mcp_tools import search_chunks_handler, SearchChunksInput
result = await search_chunks_handler(
SearchChunksInput(query="nominalism", limit=10)
)
```
### `example_direct_pipeline.py`
Utilisation directe du pipeline PDF:
```python
from utils.pdf_pipeline import process_pdf
result = process_pdf(
Path("document.pdf"),
use_llm=True,
ingest_to_weaviate=True
)
```
## Architecture
```
┌─────────────────────────────────────────┐
│ Votre Application │
│ │
│ Claude/Mistral (LLM conversationnel) │
│ ↓ │
│ MCPClient (stdio JSON-RPC) │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ MCP Server (subprocess) │
│ - 9 outils disponibles │
│ - search_chunks, parse_pdf, etc. │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Weaviate + BGE-M3 embeddings │
│ - 5,180 chunks de Peirce │
│ - Recherche sémantique │
└─────────────────────────────────────────┘
```
## Embeddings vs LLM
**Important:** Trois modèles distincts sont utilisés:
1. **BGE-M3** (text2vec-transformers dans Weaviate)
- Rôle: Vectorisation (embeddings 1024-dim)
- Quand: Ingestion + recherche
- Non modifiable sans migration
2. **Claude/Mistral** (Agent conversationnel)
- Rôle: Comprendre questions + synthétiser réponses
- Quand: Chaque conversation utilisateur
- Changeable (votre choix)
3. **Mistral OCR** (pixtral-12b)
- Rôle: Extraction texte depuis PDF
- Quand: Ingestion de PDFs (via parse_pdf tool)
- Fixé par le MCP server
## Outils MCP disponibles
| Outil | Description |
|-------|-------------|
| `search_chunks` | Recherche sémantique (500 max) |
| `search_summaries` | Recherche dans résumés |
| `list_documents` | Liste tous les documents |
| `get_document` | Récupère un document spécifique |
| `get_chunks_by_document` | Chunks d'un document |
| `filter_by_author` | Filtre par auteur |
| `parse_pdf` | Ingère un PDF/Markdown |
| `delete_document` | Supprime un document |
| `ping` | Health check |
## Limitations connues
Voir `KNOWN_ISSUES.md` pour les détails:
- ⚠️ `author_filter` et `work_filter` ne fonctionnent pas (limitation Weaviate nested objects)
- ✅ Workaround: Utiliser `filter_by_author` tool à la place
## Requirements
```bash
pip install anthropic python-dotenv # Pour Claude
# OU
pip install mistralai # Pour Mistral
```
Toutes les dépendances sont dans `requirements.txt` du projet parent.

View File

@@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""
Exemple d'utilisation DIRECTE du pipeline PDF (sans MCP).
Plus simple et plus de contrôle sur les paramètres!
"""
from pathlib import Path
from utils.pdf_pipeline import process_pdf, process_pdf_bytes
import weaviate
from weaviate.classes.query import Filter
def example_process_local_file():
"""Traiter un fichier local (PDF ou Markdown)."""
result = process_pdf(
pdf_path=Path("md/peirce_collected_papers_fixed.md"),
output_dir=Path("output"),
# Paramètres personnalisables
skip_ocr=True, # Déjà en Markdown
use_llm=False, # Pas besoin de LLM pour Peirce
use_semantic_chunking=False, # Chunking basique (rapide)
ingest_to_weaviate=True, # Ingérer dans Weaviate
)
if result.get("success"):
print(f"{result['document_name']}: {result['chunks_count']} chunks")
print(f" Coût total: {result['cost_total']:.4f}")
else:
print(f"✗ Erreur: {result.get('error')}")
def example_process_from_url():
"""Télécharger et traiter depuis une URL."""
import httpx
url = "https://example.com/document.pdf"
# Télécharger
response = httpx.get(url, follow_redirects=True)
pdf_bytes = response.content
# Traiter
result = process_pdf_bytes(
file_bytes=pdf_bytes,
filename="document.pdf",
output_dir=Path("output"),
# Paramètres optimaux
use_llm=True,
llm_provider="mistral", # Ou "ollama"
use_semantic_chunking=True,
ingest_to_weaviate=True,
)
return result
def example_search():
"""Rechercher directement dans Weaviate."""
client = weaviate.connect_to_local()
try:
collection = client.collections.get('Chunk')
# Recherche sémantique
response = collection.query.near_text(
query="nominalism and realism",
limit=10,
)
print(f"Trouvé {len(response.objects)} résultats:")
for obj in response.objects[:3]:
props = obj.properties
print(f"\n- {props.get('sectionPath', 'N/A')}")
print(f" {props.get('text', '')[:150]}...")
finally:
client.close()
if __name__ == "__main__":
# Choisir un exemple
# example_process_local_file()
# example_process_from_url()
example_search()

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""
Exemple d'utilisation de Library RAG depuis une application Python.
Le MCP server est uniquement pour Claude Desktop.
Pour Python, appelez directement les handlers!
"""
import asyncio
from pathlib import Path
# Import direct des handlers
from mcp_tools import (
parse_pdf_handler,
ParsePdfInput,
search_chunks_handler,
SearchChunksInput,
)
async def example_parse_pdf():
"""Exemple: Traiter un PDF ou Markdown."""
# Depuis un chemin local
input_data = ParsePdfInput(
pdf_path="C:/Users/david/Documents/platon.pdf"
)
# OU depuis une URL
# input_data = ParsePdfInput(
# pdf_path="https://example.com/aristotle.pdf"
# )
# OU un fichier Markdown
# input_data = ParsePdfInput(
# pdf_path="/path/to/peirce.md"
# )
result = await parse_pdf_handler(input_data)
if result.success:
print(f"✓ Document traité: {result.document_name}")
print(f" Pages: {result.pages}")
print(f" Chunks: {result.chunks_count}")
print(f" Coût: {result.cost_total:.4f}")
else:
print(f"✗ Erreur: {result.error}")
async def example_search():
"""Exemple: Rechercher dans les chunks."""
input_data = SearchChunksInput(
query="nominalism and realism",
limit=10,
author_filter="Charles Sanders Peirce", # Optionnel
)
result = await search_chunks_handler(input_data)
print(f"Trouvé {result.total_count} résultats:")
for i, chunk in enumerate(result.results[:5], 1):
print(f"\n[{i}] Similarité: {chunk.similarity:.3f}")
print(f" {chunk.text[:200]}...")
async def main():
"""Point d'entrée principal."""
# Exemple 1: Traiter un PDF
# await example_parse_pdf()
# Exemple 2: Rechercher
await example_search()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,359 +0,0 @@
#!/usr/bin/env python3
"""
MCP Client pour Library RAG avec Claude (Anthropic).
Implémentation d'un client MCP qui permet à Claude d'utiliser
les outils de Library RAG via tool calling.
Usage:
python mcp_client_claude.py
Requirements:
pip install anthropic python-dotenv
"""
import asyncio
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Charger les variables d'environnement depuis .env
try:
from dotenv import load_dotenv
# Charger depuis le .env du projet parent
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
print(f"[ENV] Loaded environment from {env_path}")
except ImportError:
print("[ENV] python-dotenv not installed, using system environment variables")
print(" Install with: pip install python-dotenv")
@dataclass
class ToolDefinition:
"""Définition d'un outil MCP."""
name: str
description: str
input_schema: dict[str, Any]
class MCPClient:
"""Client pour communiquer avec le MCP server de Library RAG."""
def __init__(self, server_path: str, env: dict[str, str] | None = None):
"""
Args:
server_path: Chemin vers mcp_server.py
env: Variables d'environnement additionnelles
"""
self.server_path = server_path
self.env = env or {}
self.process = None
self.request_id = 0
async def start(self) -> None:
"""Démarrer le MCP server subprocess."""
print(f"[MCP] Starting server: {self.server_path}")
# Préparer l'environnement
full_env = {**os.environ, **self.env}
# Démarrer le subprocess
self.process = await asyncio.create_subprocess_exec(
sys.executable,
self.server_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=full_env,
)
# Phase 1: Initialize
init_result = await self._send_request(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"clientInfo": {"name": "library-rag-client-claude", "version": "1.0.0"},
},
)
print(f"[MCP] Server initialized: {init_result.get('serverInfo', {}).get('name')}")
# Phase 2: Initialized notification
await self._send_notification("notifications/initialized", {})
print("[MCP] Client ready")
async def _send_request(self, method: str, params: dict) -> dict:
"""Envoyer une requête JSON-RPC et attendre la réponse."""
self.request_id += 1
request = {
"jsonrpc": "2.0",
"id": self.request_id,
"method": method,
"params": params,
}
# Envoyer
request_json = json.dumps(request) + "\n"
self.process.stdin.write(request_json.encode())
await self.process.stdin.drain()
# Recevoir
response_line = await self.process.stdout.readline()
if not response_line:
raise RuntimeError("MCP server closed connection")
response = json.loads(response_line.decode())
# Vérifier erreurs
if "error" in response:
raise RuntimeError(f"MCP error: {response['error']}")
return response.get("result", {})
async def _send_notification(self, method: str, params: dict) -> None:
"""Envoyer une notification (pas de réponse)."""
notification = {"jsonrpc": "2.0", "method": method, "params": params}
notification_json = json.dumps(notification) + "\n"
self.process.stdin.write(notification_json.encode())
await self.process.stdin.drain()
async def list_tools(self) -> list[ToolDefinition]:
"""Obtenir la liste des outils disponibles."""
result = await self._send_request("tools/list", {})
tools = result.get("tools", [])
tool_defs = [
ToolDefinition(
name=tool["name"],
description=tool["description"],
input_schema=tool["inputSchema"],
)
for tool in tools
]
print(f"[MCP] Found {len(tool_defs)} tools")
return tool_defs
async def call_tool(self, tool_name: str, arguments: dict) -> Any:
"""Appeler un outil MCP."""
print(f"[MCP] Calling tool: {tool_name}")
print(f" Arguments: {json.dumps(arguments, indent=2)[:200]}...")
result = await self._send_request(
"tools/call", {"name": tool_name, "arguments": arguments}
)
# Extraire le contenu
content = result.get("content", [])
if content and content[0].get("type") == "text":
text_content = content[0]["text"]
try:
return json.loads(text_content)
except json.JSONDecodeError:
return text_content
return result
async def stop(self) -> None:
"""Arrêter le MCP server."""
if self.process:
print("[MCP] Stopping server...")
self.process.terminate()
await self.process.wait()
print("[MCP] Server stopped")
class ClaudeWithMCP:
"""Claude avec capacité d'utiliser les outils MCP."""
def __init__(self, mcp_client: MCPClient, anthropic_api_key: str):
"""
Args:
mcp_client: Client MCP initialisé
anthropic_api_key: Clé API Anthropic
"""
self.mcp_client = mcp_client
self.anthropic_api_key = anthropic_api_key
self.tools = None
self.messages = []
# Import Claude
try:
from anthropic import Anthropic
self.client = Anthropic(api_key=anthropic_api_key)
except ImportError:
raise ImportError("Install anthropic: pip install anthropic")
async def initialize(self) -> None:
"""Charger les outils MCP et les convertir pour Claude."""
mcp_tools = await self.mcp_client.list_tools()
# Convertir au format Claude (identique au format MCP)
self.tools = [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
for tool in mcp_tools
]
print(f"[Claude] Loaded {len(self.tools)} tools")
async def chat(self, user_message: str, max_iterations: int = 10) -> str:
"""
Converser avec Claude qui peut utiliser les outils MCP.
Args:
user_message: Message de l'utilisateur
max_iterations: Limite de tool calls
Returns:
Réponse finale de Claude
"""
print(f"\n[USER] {user_message}\n")
self.messages.append({"role": "user", "content": user_message})
for iteration in range(max_iterations):
print(f"[Claude] Iteration {iteration + 1}/{max_iterations}")
# Appel Claude avec tools
response = self.client.messages.create(
model="claude-sonnet-4-5-20250929", # Claude Sonnet 4.5
max_tokens=4096,
messages=self.messages,
tools=self.tools,
)
# Ajouter la réponse de Claude
assistant_message = {
"role": "assistant",
"content": response.content,
}
self.messages.append(assistant_message)
# Vérifier si Claude veut utiliser des outils
tool_uses = [block for block in response.content if block.type == "tool_use"]
# Si pas de tool use → réponse finale
if not tool_uses:
# Extraire le texte de la réponse
text_blocks = [block for block in response.content if block.type == "text"]
if text_blocks:
print(f"[Claude] Final response")
return text_blocks[0].text
return ""
# Exécuter les tool uses
print(f"[Claude] Tool uses: {len(tool_uses)}")
tool_results = []
for tool_use in tool_uses:
tool_name = tool_use.name
arguments = tool_use.input
# Appeler via MCP
try:
result = await self.mcp_client.call_tool(tool_name, arguments)
result_str = json.dumps(result) if isinstance(result, dict) else str(result)
print(f"[MCP] Result: {result_str[:200]}...")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result_str,
})
except Exception as e:
print(f"[MCP] Error: {e}")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": json.dumps({"error": str(e)}),
"is_error": True,
})
# Ajouter les résultats des outils
self.messages.append({
"role": "user",
"content": tool_results,
})
return "Max iterations atteintes"
async def main():
"""Exemple d'utilisation du client MCP avec Claude."""
# Configuration
library_rag_path = Path(__file__).parent.parent
server_path = library_rag_path / "mcp_server.py"
anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")
if not anthropic_api_key:
print("ERROR: ANTHROPIC_API_KEY not found in .env file")
print("Please add to .env: ANTHROPIC_API_KEY=your_key")
return
mistral_api_key = os.getenv("MISTRAL_API_KEY")
if not mistral_api_key:
print("ERROR: MISTRAL_API_KEY not found in .env file")
print("The MCP server needs Mistral API for OCR functionality")
return
# 1. Créer et démarrer le client MCP
mcp_client = MCPClient(
server_path=str(server_path),
env={
"MISTRAL_API_KEY": mistral_api_key or "",
},
)
try:
await mcp_client.start()
# 2. Créer l'agent Claude
agent = ClaudeWithMCP(mcp_client, anthropic_api_key)
await agent.initialize()
# 3. Exemples de conversations
print("\n" + "=" * 80)
print("EXAMPLE 1: Search in Peirce")
print("=" * 80)
response = await agent.chat(
"What did Charles Sanders Peirce say about the philosophical debate "
"between nominalism and realism? Search the database and provide "
"a detailed summary with specific quotes."
)
print(f"\n[CLAUDE]\n{response}\n")
print("\n" + "=" * 80)
print("EXAMPLE 2: Explore database")
print("=" * 80)
response = await agent.chat(
"What documents are available in the database? "
"Give me an overview of the authors and topics covered."
)
print(f"\n[CLAUDE]\n{response}\n")
finally:
await mcp_client.stop()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,347 +0,0 @@
#!/usr/bin/env python3
"""
MCP Client de référence pour Library RAG.
Implémentation complète d'un client MCP qui permet à un LLM
d'utiliser les outils de Library RAG.
Usage:
python mcp_client_reference.py
Requirements:
pip install mistralai anyio
"""
import asyncio
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class ToolDefinition:
"""Définition d'un outil MCP."""
name: str
description: str
input_schema: dict[str, Any]
class MCPClient:
"""Client pour communiquer avec le MCP server de Library RAG."""
def __init__(self, server_path: str, env: dict[str, str] | None = None):
"""
Args:
server_path: Chemin vers mcp_server.py
env: Variables d'environnement additionnelles
"""
self.server_path = server_path
self.env = env or {}
self.process = None
self.request_id = 0
async def start(self) -> None:
"""Démarrer le MCP server subprocess."""
print(f"[MCP] Starting server: {self.server_path}")
# Préparer l'environnement
full_env = {**os.environ, **self.env}
# Démarrer le subprocess
self.process = await asyncio.create_subprocess_exec(
sys.executable, # Python executable
self.server_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=full_env,
)
# Phase 1: Initialize
init_result = await self._send_request(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"clientInfo": {"name": "library-rag-client", "version": "1.0.0"},
},
)
print(f"[MCP] Server initialized: {init_result.get('serverInfo', {}).get('name')}")
# Phase 2: Initialized notification
await self._send_notification("notifications/initialized", {})
print("[MCP] Client ready")
async def _send_request(self, method: str, params: dict) -> dict:
"""Envoyer une requête JSON-RPC et attendre la réponse."""
self.request_id += 1
request = {
"jsonrpc": "2.0",
"id": self.request_id,
"method": method,
"params": params,
}
# Envoyer
request_json = json.dumps(request) + "\n"
self.process.stdin.write(request_json.encode())
await self.process.stdin.drain()
# Recevoir
response_line = await self.process.stdout.readline()
if not response_line:
raise RuntimeError("MCP server closed connection")
response = json.loads(response_line.decode())
# Vérifier erreurs
if "error" in response:
raise RuntimeError(f"MCP error: {response['error']}")
return response.get("result", {})
async def _send_notification(self, method: str, params: dict) -> None:
"""Envoyer une notification (pas de réponse)."""
notification = {"jsonrpc": "2.0", "method": method, "params": params}
notification_json = json.dumps(notification) + "\n"
self.process.stdin.write(notification_json.encode())
await self.process.stdin.drain()
async def list_tools(self) -> list[ToolDefinition]:
"""Obtenir la liste des outils disponibles."""
result = await self._send_request("tools/list", {})
tools = result.get("tools", [])
tool_defs = [
ToolDefinition(
name=tool["name"],
description=tool["description"],
input_schema=tool["inputSchema"],
)
for tool in tools
]
print(f"[MCP] Found {len(tool_defs)} tools")
return tool_defs
async def call_tool(self, tool_name: str, arguments: dict) -> Any:
"""Appeler un outil MCP."""
print(f"[MCP] Calling tool: {tool_name}")
print(f" Arguments: {json.dumps(arguments, indent=2)}")
result = await self._send_request(
"tools/call", {"name": tool_name, "arguments": arguments}
)
# Extraire le contenu
content = result.get("content", [])
if content and content[0].get("type") == "text":
text_content = content[0]["text"]
try:
return json.loads(text_content)
except json.JSONDecodeError:
return text_content
return result
async def stop(self) -> None:
"""Arrêter le MCP server."""
if self.process:
print("[MCP] Stopping server...")
self.process.terminate()
await self.process.wait()
print("[MCP] Server stopped")
class LLMWithMCP:
"""LLM avec capacité d'utiliser les outils MCP."""
def __init__(self, mcp_client: MCPClient, mistral_api_key: str):
"""
Args:
mcp_client: Client MCP initialisé
mistral_api_key: Clé API Mistral
"""
self.mcp_client = mcp_client
self.mistral_api_key = mistral_api_key
self.tools = None
self.messages = []
# Import Mistral
try:
from mistralai import Mistral
self.mistral = Mistral(api_key=mistral_api_key)
except ImportError:
raise ImportError("Install mistralai: pip install mistralai")
async def initialize(self) -> None:
"""Charger les outils MCP et les convertir pour Mistral."""
mcp_tools = await self.mcp_client.list_tools()
# Convertir au format Mistral
self.tools = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
},
}
for tool in mcp_tools
]
print(f"[LLM] Loaded {len(self.tools)} tools for Mistral")
async def chat(self, user_message: str, max_iterations: int = 10) -> str:
"""
Converser avec le LLM qui peut utiliser les outils MCP.
Args:
user_message: Message de l'utilisateur
max_iterations: Limite de tool calls
Returns:
Réponse finale du LLM
"""
print(f"\n[USER] {user_message}\n")
self.messages.append({"role": "user", "content": user_message})
for iteration in range(max_iterations):
print(f"[LLM] Iteration {iteration + 1}/{max_iterations}")
# Appel LLM avec tools
response = self.mistral.chat.complete(
model="mistral-large-latest",
messages=self.messages,
tools=self.tools,
tool_choice="auto",
)
assistant_message = response.choices[0].message
# Ajouter le message assistant
self.messages.append(
{
"role": "assistant",
"content": assistant_message.content or "",
"tool_calls": (
[
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in assistant_message.tool_calls
]
if assistant_message.tool_calls
else None
),
}
)
# Si pas de tool calls → réponse finale
if not assistant_message.tool_calls:
print(f"[LLM] Final response")
return assistant_message.content
# Exécuter les tool calls
print(f"[LLM] Tool calls: {len(assistant_message.tool_calls)}")
for tool_call in assistant_message.tool_calls:
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# Appeler via MCP
try:
result = await self.mcp_client.call_tool(tool_name, arguments)
result_str = json.dumps(result)
print(f"[MCP] Result: {result_str[:200]}...")
except Exception as e:
result_str = json.dumps({"error": str(e)})
print(f"[MCP] Error: {e}")
# Ajouter le résultat
self.messages.append(
{
"role": "tool",
"name": tool_name,
"content": result_str,
"tool_call_id": tool_call.id,
}
)
return "Max iterations atteintes"
async def main():
"""Exemple d'utilisation du client MCP."""
# Configuration
library_rag_path = Path(__file__).parent.parent
server_path = library_rag_path / "mcp_server.py"
mistral_api_key = os.getenv("MISTRAL_API_KEY")
if not mistral_api_key:
print("ERROR: MISTRAL_API_KEY not set")
return
# 1. Créer et démarrer le client MCP
mcp_client = MCPClient(
server_path=str(server_path),
env={
"MISTRAL_API_KEY": mistral_api_key,
# Ajouter autres variables si nécessaire
},
)
try:
await mcp_client.start()
# 2. Créer l'agent LLM
agent = LLMWithMCP(mcp_client, mistral_api_key)
await agent.initialize()
# 3. Exemples de conversations
print("\n" + "=" * 80)
print("EXAMPLE 1: Search")
print("=" * 80)
response = await agent.chat(
"What did Charles Sanders Peirce say about the debate between "
"nominalism and realism? Search the database and give me a summary "
"with specific quotes."
)
print(f"\n[ASSISTANT]\n{response}\n")
print("\n" + "=" * 80)
print("EXAMPLE 2: List documents")
print("=" * 80)
response = await agent.chat(
"List all the documents in the database. "
"How many are there and who are the authors?"
)
print(f"\n[ASSISTANT]\n{response}\n")
finally:
await mcp_client.stop()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""
Test simple du client MCP (sans LLM).
Teste la communication directe avec le MCP server.
Usage:
python test_mcp_client.py
"""
import asyncio
import json
import os
import sys
from pathlib import Path
# Ajouter le parent au path pour import
sys.path.insert(0, str(Path(__file__).parent))
from mcp_client_reference import MCPClient
async def test_basic_communication():
"""Test: Communication basique avec le server."""
print("TEST 1: Basic Communication")
print("-" * 80)
library_rag_path = Path(__file__).parent.parent
server_path = library_rag_path / "mcp_server.py"
client = MCPClient(
server_path=str(server_path),
env={"MISTRAL_API_KEY": os.getenv("MISTRAL_API_KEY", "")},
)
try:
await client.start()
print("[OK] Server started\n")
# Liste des outils
tools = await client.list_tools()
print(f"[OK] Found {len(tools)} tools:")
for tool in tools:
print(f" - {tool.name}: {tool.description}")
print("\n[OK] Test passed")
finally:
await client.stop()
async def test_search_chunks():
"""Test: Recherche sémantique."""
print("\n\nTEST 2: Search Chunks")
print("-" * 80)
library_rag_path = Path(__file__).parent.parent
server_path = library_rag_path / "mcp_server.py"
client = MCPClient(
server_path=str(server_path),
env={"MISTRAL_API_KEY": os.getenv("MISTRAL_API_KEY", "")},
)
try:
await client.start()
# Recherche
result = await client.call_tool(
"search_chunks",
{
"query": "nominalism and realism",
"limit": 3,
"author_filter": "Charles Sanders Peirce",
},
)
print(f"[OK] Query: nominalism and realism")
print(f"[OK] Found {result['total_count']} results")
for i, chunk in enumerate(result["results"][:3], 1):
print(f"\n [{i}] Similarity: {chunk['similarity']:.3f}")
print(f" Section: {chunk['section_path']}")
print(f" Preview: {chunk['text'][:150]}...")
print("\n[OK] Test passed")
finally:
await client.stop()
async def test_list_documents():
"""Test: Liste des documents."""
print("\n\nTEST 3: List Documents")
print("-" * 80)
library_rag_path = Path(__file__).parent.parent
server_path = library_rag_path / "mcp_server.py"
client = MCPClient(
server_path=str(server_path),
env={"MISTRAL_API_KEY": os.getenv("MISTRAL_API_KEY", "")},
)
try:
await client.start()
result = await client.call_tool("list_documents", {"limit": 10})
print(f"[OK] Total documents: {result['total_count']}")
for doc in result["documents"][:5]:
print(f"\n - {doc['source_id']}")
print(f" Author: {doc['author']}")
print(f" Chunks: {doc['chunks_count']}")
print("\n[OK] Test passed")
finally:
await client.stop()
async def test_get_document():
"""Test: Récupérer un document spécifique."""
print("\n\nTEST 4: Get Document")
print("-" * 80)
library_rag_path = Path(__file__).parent.parent
server_path = library_rag_path / "mcp_server.py"
client = MCPClient(
server_path=str(server_path),
env={"MISTRAL_API_KEY": os.getenv("MISTRAL_API_KEY", "")},
)
try:
await client.start()
# D'abord lister pour trouver un document
list_result = await client.call_tool("list_documents", {"limit": 1})
if list_result["documents"]:
doc_id = list_result["documents"][0]["source_id"]
# Récupérer le document
result = await client.call_tool(
"get_document",
{"source_id": doc_id, "include_chunks": True, "chunk_limit": 5},
)
print(f"[OK] Document: {result['source_id']}")
print(f" Author: {result['author']}")
print(f" Pages: {result['pages']}")
print(f" Chunks: {result['chunks_count']}")
if result.get("chunks"):
print(f"\n First chunk preview:")
print(f" {result['chunks'][0]['text'][:200]}...")
print("\n[OK] Test passed")
else:
print("[WARN] No documents in database")
finally:
await client.stop()
async def main():
"""Exécuter tous les tests."""
print("=" * 80)
print("MCP CLIENT TESTS")
print("=" * 80)
try:
await test_basic_communication()
await test_search_chunks()
await test_list_documents()
await test_get_document()
print("\n" + "=" * 80)
print("ALL TESTS PASSED [OK]")
print("=" * 80)
except Exception as e:
print(f"\n[ERROR] Test failed: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,62 +0,0 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from mcp_client_reference import MCPClient
async def main():
client = MCPClient(server_path=str(Path(__file__).parent.parent / "mcp_server.py"), env={})
await client.start()
try:
print("=" * 70)
print("MCP CLIENT - FUNCTIONAL TESTS")
print("=" * 70)
# Test 1: Search chunks
print("\n[TEST 1] Search chunks (semantic search)")
result = await client.call_tool("search_chunks", {
"query": "nominalism realism debate",
"limit": 2
})
print(f"Results: {result['total_count']}")
for i, chunk in enumerate(result['results'], 1):
print(f" [{i}] {chunk['work_author']} - Similarity: {chunk['similarity']:.3f}")
print(f" {chunk['text'][:80]}...")
print("[OK]")
# Test 2: List documents
print("\n[TEST 2] List documents")
result = await client.call_tool("list_documents", {"limit": 5})
print(f"Total: {result['total_count']} documents")
for doc in result['documents'][:3]:
print(f" - {doc['source_id']} ({doc['work_author']}): {doc['chunks_count']} chunks")
print("[OK]")
# Test 3: Filter by author
print("\n[TEST 3] Filter by author")
result = await client.call_tool("filter_by_author", {
"author": "Charles Sanders Peirce"
})
print(f"Author: {result['author']}")
print(f"Works: {result['total_works']}")
print(f"Documents: {result['total_documents']}")
if 'total_chunks' in result:
print(f"Chunks: {result['total_chunks']}")
print("[OK]")
print("\n" + "=" * 70)
print("ALL TESTS PASSED - MCP CLIENT IS WORKING!")
print("=" * 70)
print("\nNote: author_filter and work_filter parameters are not supported")
print(" due to Weaviate v4 limitation. See examples/KNOWN_ISSUES.md")
finally:
await client.stop()
asyncio.run(main())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,313 +0,0 @@
"""Script de migration pour ajouter le champ 'summary' à la collection Chunk.
Ce script :
1. Exporte toutes les données existantes (Work, Document, Chunk, Summary)
2. Supprime et recrée le schéma avec le nouveau champ 'summary' vectorisé
3. Réimporte toutes les données avec summary="" par défaut pour les chunks
Usage:
python migrate_add_summary.py
ATTENTION: Ce script supprime et recrée le schéma. Assurez-vous que:
- Weaviate est en cours d'exécution (docker compose up -d)
- Vous avez un backup manuel si nécessaire (recommandé)
"""
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
import weaviate
from weaviate.collections import Collection
# Importer les fonctions de création de schéma
from schema import create_schema
# Configuration logging
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("migration.log", encoding="utf-8")
]
)
logger = logging.getLogger(__name__)
# =============================================================================
# Fonctions d'export
# =============================================================================
def export_collection(
client: weaviate.WeaviateClient,
collection_name: str,
output_dir: Path
) -> int:
"""Exporte toutes les données d'une collection vers un fichier JSON.
Args:
client: Client Weaviate connecté.
collection_name: Nom de la collection à exporter.
output_dir: Répertoire de sortie.
Returns:
Nombre d'objets exportés.
"""
logger.info(f"Export de la collection '{collection_name}'...")
try:
collection = client.collections.get(collection_name)
# Récupérer tous les objets (pas de limite)
objects = []
cursor = None
batch_size = 1000
while True:
if cursor:
response = collection.query.fetch_objects(
limit=batch_size,
after=cursor
)
else:
response = collection.query.fetch_objects(limit=batch_size)
if not response.objects:
break
for obj in response.objects:
# Extraire UUID et propriétés
obj_data = {
"uuid": str(obj.uuid),
"properties": obj.properties
}
objects.append(obj_data)
# Continuer si plus d'objets disponibles
if len(response.objects) < batch_size:
break
cursor = response.objects[-1].uuid
# Sauvegarder dans un fichier JSON
output_file = output_dir / f"{collection_name.lower()}_backup.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(objects, f, indent=2, ensure_ascii=False, default=str)
logger.info(f"{len(objects)} objets exportés vers {output_file}")
return len(objects)
except Exception as e:
logger.error(f" ✗ Erreur lors de l'export de {collection_name}: {e}")
return 0
def export_all_data(client: weaviate.WeaviateClient) -> Path:
"""Exporte toutes les collections vers un dossier de backup.
Args:
client: Client Weaviate connecté.
Returns:
Path du dossier de backup créé.
"""
# Créer un dossier de backup avec timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = Path(f"backup_migration_{timestamp}")
backup_dir.mkdir(exist_ok=True)
logger.info("=" * 80)
logger.info("EXPORT DES DONNÉES EXISTANTES")
logger.info("=" * 80)
collections = ["Work", "Document", "Chunk", "Summary"]
total_objects = 0
for collection_name in collections:
count = export_collection(client, collection_name, backup_dir)
total_objects += count
logger.info(f"\n✓ Total exporté: {total_objects} objets dans {backup_dir}")
return backup_dir
# =============================================================================
# Fonctions d'import
# =============================================================================
def import_collection(
client: weaviate.WeaviateClient,
collection_name: str,
backup_file: Path,
add_summary_field: bool = False
) -> int:
"""Importe les données d'un fichier JSON vers une collection Weaviate.
Args:
client: Client Weaviate connecté.
collection_name: Nom de la collection cible.
backup_file: Fichier JSON source.
add_summary_field: Si True, ajoute un champ 'summary' vide (pour Chunk).
Returns:
Nombre d'objets importés.
"""
logger.info(f"Import de la collection '{collection_name}'...")
if not backup_file.exists():
logger.warning(f" ⚠ Fichier {backup_file} introuvable, skip")
return 0
try:
with open(backup_file, "r", encoding="utf-8") as f:
objects = json.load(f)
if not objects:
logger.info(f" ⚠ Aucun objet à importer pour {collection_name}")
return 0
collection = client.collections.get(collection_name)
# Préparer les objets pour l'insertion
objects_to_insert = []
for obj in objects:
props = obj["properties"]
# Ajouter le champ summary vide pour les chunks
if add_summary_field:
props["summary"] = ""
objects_to_insert.append(props)
# Insertion par batch (plus efficace)
batch_size = 100
total_inserted = 0
for i in range(0, len(objects_to_insert), batch_size):
batch = objects_to_insert[i:i + batch_size]
try:
collection.data.insert_many(batch)
total_inserted += len(batch)
if (i // batch_size + 1) % 10 == 0:
logger.info(f"{total_inserted}/{len(objects_to_insert)} objets insérés...")
except Exception as e:
logger.error(f" ✗ Erreur lors de l'insertion du batch {i//batch_size + 1}: {e}")
# Continuer avec le batch suivant
logger.info(f"{total_inserted} objets importés dans {collection_name}")
return total_inserted
except Exception as e:
logger.error(f" ✗ Erreur lors de l'import de {collection_name}: {e}")
return 0
def import_all_data(client: weaviate.WeaviateClient, backup_dir: Path) -> None:
"""Importe toutes les données depuis un dossier de backup.
Args:
client: Client Weaviate connecté.
backup_dir: Dossier contenant les fichiers de backup.
"""
logger.info("\n" + "=" * 80)
logger.info("IMPORT DES DONNÉES")
logger.info("=" * 80)
# Ordre d'import: Work → Document → Chunk/Summary
import_collection(client, "Work", backup_dir / "work_backup.json")
import_collection(client, "Document", backup_dir / "document_backup.json")
import_collection(
client,
"Chunk",
backup_dir / "chunk_backup.json",
add_summary_field=True # Ajouter le champ summary vide
)
import_collection(client, "Summary", backup_dir / "summary_backup.json")
logger.info("\n✓ Import terminé")
# =============================================================================
# Script principal
# =============================================================================
def main() -> None:
"""Fonction principale de migration."""
logger.info("=" * 80)
logger.info("MIGRATION: Ajout du champ 'summary' à la collection Chunk")
logger.info("=" * 80)
# Connexion à Weaviate
logger.info("\n[1/5] Connexion à Weaviate...")
try:
client = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
logger.info(" ✓ Connexion établie")
except Exception as e:
logger.error(f" ✗ Erreur de connexion: {e}")
logger.error(" → Vérifiez que Weaviate est lancé (docker compose up -d)")
sys.exit(1)
try:
# Étape 1: Export des données
logger.info("\n[2/5] Export des données existantes...")
backup_dir = export_all_data(client)
# Étape 2: Recréation du schéma
logger.info("\n[3/5] Suppression et recréation du schéma...")
create_schema(client, delete_existing=True)
logger.info(" ✓ Nouveau schéma créé avec champ 'summary' vectorisé")
# Étape 3: Réimport des données
logger.info("\n[4/5] Réimport des données...")
import_all_data(client, backup_dir)
# Étape 4: Vérification
logger.info("\n[5/5] Vérification...")
chunk_collection = client.collections.get("Chunk")
count = len(chunk_collection.query.fetch_objects(limit=1).objects)
if count > 0:
# Vérifier qu'un chunk a bien le champ summary
sample = chunk_collection.query.fetch_objects(limit=1).objects[0]
if "summary" in sample.properties:
logger.info(" ✓ Champ 'summary' présent dans les chunks")
else:
logger.warning(" ⚠ Champ 'summary' manquant (vérifier schema.py)")
logger.info("\n" + "=" * 80)
logger.info("MIGRATION TERMINÉE AVEC SUCCÈS!")
logger.info("=" * 80)
logger.info(f"\n✓ Backup sauvegardé dans: {backup_dir}")
logger.info("✓ Schéma mis à jour avec champ 'summary' vectorisé")
logger.info("✓ Toutes les données ont été restaurées")
logger.info("\nProchaine étape:")
logger.info(" → Lancez utils/generate_chunk_summaries.py pour générer les résumés")
logger.info("=" * 80)
except Exception as e:
logger.error(f"\n✗ ERREUR CRITIQUE: {e}")
logger.error("La migration a échoué. Vérifiez les logs dans migration.log")
sys.exit(1)
finally:
client.close()
logger.info("\n✓ Connexion Weaviate fermée")
if __name__ == "__main__":
# Vérifier l'encodage Windows
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
main()

View File

@@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""Generate statistics for WEAVIATE_SCHEMA.md documentation.
This script queries Weaviate and generates updated statistics to keep
the schema documentation in sync with reality.
Usage:
python generate_schema_stats.py
Output:
Prints formatted markdown table with current statistics that can be
copy-pasted into WEAVIATE_SCHEMA.md
"""
import sys
from datetime import datetime
from typing import Dict
import weaviate
def get_collection_stats(client: weaviate.WeaviateClient) -> Dict[str, int]:
"""Get object counts for all collections.
Args:
client: Connected Weaviate client.
Returns:
Dict mapping collection name to object count.
"""
stats: Dict[str, int] = {}
collections = client.collections.list_all()
for name in ["Work", "Document", "Chunk", "Summary"]:
if name in collections:
try:
coll = client.collections.get(name)
result = coll.aggregate.over_all(total_count=True)
stats[name] = result.total_count
except Exception as e:
print(f"Warning: Could not get count for {name}: {e}", file=sys.stderr)
stats[name] = 0
else:
stats[name] = 0
return stats
def print_markdown_stats(stats: Dict[str, int]) -> None:
"""Print statistics in markdown table format for WEAVIATE_SCHEMA.md.
Args:
stats: Dict mapping collection name to object count.
"""
total_vectors = stats["Chunk"] + stats["Summary"]
ratio = stats["Summary"] / stats["Chunk"] if stats["Chunk"] > 0 else 0
today = datetime.now().strftime("%d/%m/%Y")
print(f"## Contenu actuel (au {today})")
print()
print(f"**Dernière vérification** : {datetime.now().strftime('%d %B %Y')} via `generate_schema_stats.py`")
print()
print("### Statistiques par collection")
print()
print("| Collection | Objets | Vectorisé | Utilisation |")
print("|------------|--------|-----------|-------------|")
print(f"| **Chunk** | **{stats['Chunk']:,}** | ✅ Oui | Recherche sémantique principale |")
print(f"| **Summary** | **{stats['Summary']:,}** | ✅ Oui | Recherche hiérarchique (chapitres/sections) |")
print(f"| **Document** | **{stats['Document']:,}** | ❌ Non | Métadonnées d'éditions |")
print(f"| **Work** | **{stats['Work']:,}** | ✅ Oui* | Métadonnées d'œuvres (vide, prêt pour migration) |")
print()
print(f"**Total vecteurs** : {total_vectors:,} ({stats['Chunk']:,} chunks + {stats['Summary']:,} summaries)")
print(f"**Ratio Summary/Chunk** : {ratio:.2f} ", end="")
if ratio > 1:
print("(plus de summaries que de chunks, bon pour recherche hiérarchique)")
else:
print("(plus de chunks que de summaries)")
print()
print("\\* *Work est configuré avec vectorisation (depuis migration 2026-01) mais n'a pas encore d'objets*")
print()
# Additional insights
print("### Insights")
print()
if stats["Chunk"] > 0:
avg_summaries_per_chunk = stats["Summary"] / stats["Chunk"]
print(f"- **Granularité** : {avg_summaries_per_chunk:.1f} summaries par chunk en moyenne")
if stats["Document"] > 0:
avg_chunks_per_doc = stats["Chunk"] / stats["Document"]
avg_summaries_per_doc = stats["Summary"] / stats["Document"]
print(f"- **Taille moyenne document** : {avg_chunks_per_doc:.0f} chunks, {avg_summaries_per_doc:.0f} summaries")
if stats["Chunk"] >= 50000:
print("- **⚠️ Index Switch** : Collection Chunk a dépassé 50k → HNSW activé (Dynamic index)")
elif stats["Chunk"] >= 40000:
print(f"- **📊 Proche seuil** : {50000 - stats['Chunk']:,} chunks avant switch FLAT→HNSW (50k)")
if stats["Summary"] >= 10000:
print("- **⚠️ Index Switch** : Collection Summary a dépassé 10k → HNSW activé (Dynamic index)")
elif stats["Summary"] >= 8000:
print(f"- **📊 Proche seuil** : {10000 - stats['Summary']:,} summaries avant switch FLAT→HNSW (10k)")
# Memory estimation
vectors_total = total_vectors
# BGE-M3: 1024 dim × 4 bytes (float32) = 4KB per vector
# + metadata ~1KB per object
estimated_ram_gb = (vectors_total * 5) / (1024 * 1024) # 5KB per vector with metadata
estimated_ram_with_rq_gb = estimated_ram_gb * 0.25 # RQ saves 75%
print()
print(f"- **RAM estimée** : ~{estimated_ram_gb:.1f} GB sans RQ, ~{estimated_ram_with_rq_gb:.1f} GB avec RQ (économie 75%)")
print()
def main() -> None:
"""Main entry point."""
# Fix encoding for Windows console
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 80, file=sys.stderr)
print("GÉNÉRATION DES STATISTIQUES WEAVIATE", file=sys.stderr)
print("=" * 80, file=sys.stderr)
print(file=sys.stderr)
client: weaviate.WeaviateClient = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
try:
if not client.is_ready():
print("❌ Weaviate is not ready. Ensure docker-compose is running.", file=sys.stderr)
sys.exit(1)
print("✓ Weaviate is ready", file=sys.stderr)
print("✓ Querying collections...", file=sys.stderr)
stats = get_collection_stats(client)
print("✓ Statistics retrieved", file=sys.stderr)
print(file=sys.stderr)
print("=" * 80, file=sys.stderr)
print("MARKDOWN OUTPUT (copy to WEAVIATE_SCHEMA.md):", file=sys.stderr)
print("=" * 80, file=sys.stderr)
print(file=sys.stderr)
# Print to stdout (can be redirected to file)
print_markdown_stats(stats)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@@ -1,480 +0,0 @@
#!/usr/bin/env python3
"""Gérer les chunks orphelins (sans document parent).
Un chunk est orphelin si son document.sourceId ne correspond à aucun objet
dans la collection Document.
Ce script offre 3 options :
1. SUPPRIMER les chunks orphelins (perte définitive)
2. CRÉER les documents manquants (restauration)
3. LISTER seulement (ne rien faire)
Usage:
# Lister les orphelins (par défaut)
python manage_orphan_chunks.py
# Créer les documents manquants pour les orphelins
python manage_orphan_chunks.py --create-documents
# Supprimer les chunks orphelins (ATTENTION: perte de données)
python manage_orphan_chunks.py --delete-orphans
"""
import sys
import argparse
from typing import Any, Dict, List, Set
from collections import defaultdict
from datetime import datetime
import weaviate
def identify_orphan_chunks(
client: weaviate.WeaviateClient,
) -> Dict[str, List[Any]]:
"""Identifier les chunks orphelins (sans document parent).
Args:
client: Connected Weaviate client.
Returns:
Dict mapping orphan sourceId to list of orphan chunks.
"""
print("📊 Récupération de tous les chunks...")
chunk_collection = client.collections.get("Chunk")
chunks_response = chunk_collection.query.fetch_objects(
limit=10000,
)
all_chunks = chunks_response.objects
print(f"{len(all_chunks)} chunks récupérés")
print()
print("📊 Récupération de tous les documents...")
doc_collection = client.collections.get("Document")
docs_response = doc_collection.query.fetch_objects(
limit=1000,
)
print(f"{len(docs_response.objects)} documents récupérés")
print()
# Construire un set des sourceIds existants
existing_source_ids: Set[str] = set()
for doc_obj in docs_response.objects:
source_id = doc_obj.properties.get("sourceId")
if source_id:
existing_source_ids.add(source_id)
print(f"📊 {len(existing_source_ids)} sourceIds existants dans Document")
print()
# Identifier les orphelins
orphan_chunks_by_source: Dict[str, List[Any]] = defaultdict(list)
orphan_source_ids: Set[str] = set()
for chunk_obj in all_chunks:
props = chunk_obj.properties
if "document" in props and isinstance(props["document"], dict):
source_id = props["document"].get("sourceId")
if source_id and source_id not in existing_source_ids:
orphan_chunks_by_source[source_id].append(chunk_obj)
orphan_source_ids.add(source_id)
print(f"🔍 {len(orphan_source_ids)} sourceIds orphelins détectés")
print(f"🔍 {sum(len(chunks) for chunks in orphan_chunks_by_source.values())} chunks orphelins au total")
print()
return orphan_chunks_by_source
def display_orphans_report(orphan_chunks: Dict[str, List[Any]]) -> None:
"""Afficher le rapport des chunks orphelins.
Args:
orphan_chunks: Dict mapping sourceId to list of orphan chunks.
"""
if not orphan_chunks:
print("✅ Aucun chunk orphelin détecté !")
print()
return
print("=" * 80)
print("CHUNKS ORPHELINS DÉTECTÉS")
print("=" * 80)
print()
total_orphans = sum(len(chunks) for chunks in orphan_chunks.values())
print(f"📌 {len(orphan_chunks)} sourceIds orphelins")
print(f"📌 {total_orphans:,} chunks orphelins au total")
print()
for i, (source_id, chunks) in enumerate(sorted(orphan_chunks.items()), 1):
print(f"[{i}/{len(orphan_chunks)}] {source_id}")
print("" * 80)
print(f" Chunks orphelins : {len(chunks):,}")
# Extraire métadonnées depuis le premier chunk
if chunks:
first_chunk = chunks[0].properties
work = first_chunk.get("work", {})
if isinstance(work, dict):
title = work.get("title", "N/A")
author = work.get("author", "N/A")
print(f" Œuvre : {title}")
print(f" Auteur : {author}")
# Langues détectées
languages = set()
for chunk in chunks:
lang = chunk.properties.get("language")
if lang:
languages.add(lang)
if languages:
print(f" Langues : {', '.join(sorted(languages))}")
print()
print("=" * 80)
print()
def create_missing_documents(
client: weaviate.WeaviateClient,
orphan_chunks: Dict[str, List[Any]],
dry_run: bool = True,
) -> Dict[str, int]:
"""Créer les documents manquants pour les chunks orphelins.
Args:
client: Connected Weaviate client.
orphan_chunks: Dict mapping sourceId to list of orphan chunks.
dry_run: If True, only simulate (don't actually create).
Returns:
Dict with statistics: created, errors.
"""
stats = {
"created": 0,
"errors": 0,
}
if not orphan_chunks:
print("✅ Aucun document à créer (pas d'orphelins)")
return stats
if dry_run:
print("🔍 MODE DRY-RUN (simulation, aucune création réelle)")
else:
print("⚠️ MODE EXÉCUTION (création réelle)")
print("=" * 80)
print()
doc_collection = client.collections.get("Document")
for source_id, chunks in sorted(orphan_chunks.items()):
print(f"Traitement de {source_id}...")
# Extraire métadonnées depuis les chunks
if not chunks:
print(f" ⚠️ Aucun chunk, skip")
continue
first_chunk = chunks[0].properties
work = first_chunk.get("work", {})
# Construire l'objet Document avec métadonnées minimales
doc_obj: Dict[str, Any] = {
"sourceId": source_id,
"title": "N/A",
"author": "N/A",
"edition": None,
"language": "en",
"pages": 0,
"chunksCount": len(chunks),
"toc": None,
"hierarchy": None,
"createdAt": datetime.now(),
}
# Enrichir avec métadonnées work si disponibles
if isinstance(work, dict):
if work.get("title"):
doc_obj["title"] = work["title"]
if work.get("author"):
doc_obj["author"] = work["author"]
# Nested object work
doc_obj["work"] = {
"title": work.get("title", "N/A"),
"author": work.get("author", "N/A"),
}
# Détecter langue
languages = set()
for chunk in chunks:
lang = chunk.properties.get("language")
if lang:
languages.add(lang)
if len(languages) == 1:
doc_obj["language"] = list(languages)[0]
print(f" Chunks : {len(chunks):,}")
print(f" Titre : {doc_obj['title']}")
print(f" Auteur : {doc_obj['author']}")
print(f" Langue : {doc_obj['language']}")
if dry_run:
print(f" 🔍 [DRY-RUN] Créerait Document : {doc_obj}")
stats["created"] += 1
else:
try:
uuid = doc_collection.data.insert(doc_obj)
print(f" ✅ Créé UUID {uuid}")
stats["created"] += 1
except Exception as e:
print(f" ⚠️ Erreur création : {e}")
stats["errors"] += 1
print()
print("=" * 80)
print("RÉSUMÉ")
print("=" * 80)
print(f" Documents créés : {stats['created']}")
print(f" Erreurs : {stats['errors']}")
print()
return stats
def delete_orphan_chunks(
client: weaviate.WeaviateClient,
orphan_chunks: Dict[str, List[Any]],
dry_run: bool = True,
) -> Dict[str, int]:
"""Supprimer les chunks orphelins.
Args:
client: Connected Weaviate client.
orphan_chunks: Dict mapping sourceId to list of orphan chunks.
dry_run: If True, only simulate (don't actually delete).
Returns:
Dict with statistics: deleted, errors.
"""
stats = {
"deleted": 0,
"errors": 0,
}
if not orphan_chunks:
print("✅ Aucun chunk à supprimer (pas d'orphelins)")
return stats
total_to_delete = sum(len(chunks) for chunks in orphan_chunks.values())
if dry_run:
print("🔍 MODE DRY-RUN (simulation, aucune suppression réelle)")
else:
print("⚠️ MODE EXÉCUTION (suppression réelle)")
print("=" * 80)
print()
chunk_collection = client.collections.get("Chunk")
for source_id, chunks in sorted(orphan_chunks.items()):
print(f"Traitement de {source_id} ({len(chunks):,} chunks)...")
for chunk_obj in chunks:
if dry_run:
# En dry-run, compter seulement
stats["deleted"] += 1
else:
try:
chunk_collection.data.delete_by_id(chunk_obj.uuid)
stats["deleted"] += 1
except Exception as e:
print(f" ⚠️ Erreur suppression UUID {chunk_obj.uuid}: {e}")
stats["errors"] += 1
if dry_run:
print(f" 🔍 [DRY-RUN] Supprimerait {len(chunks):,} chunks")
else:
print(f" ✅ Supprimé {len(chunks):,} chunks")
print()
print("=" * 80)
print("RÉSUMÉ")
print("=" * 80)
print(f" Chunks supprimés : {stats['deleted']:,}")
print(f" Erreurs : {stats['errors']}")
print()
return stats
def verify_operation(client: weaviate.WeaviateClient) -> None:
"""Vérifier le résultat de l'opération.
Args:
client: Connected Weaviate client.
"""
print("=" * 80)
print("VÉRIFICATION POST-OPÉRATION")
print("=" * 80)
print()
orphan_chunks = identify_orphan_chunks(client)
if not orphan_chunks:
print("✅ Aucun chunk orphelin restant !")
print()
# Statistiques finales
chunk_coll = client.collections.get("Chunk")
chunk_result = chunk_coll.aggregate.over_all(total_count=True)
doc_coll = client.collections.get("Document")
doc_result = doc_coll.aggregate.over_all(total_count=True)
print(f"📊 Chunks totaux : {chunk_result.total_count:,}")
print(f"📊 Documents totaux : {doc_result.total_count:,}")
print()
else:
total_orphans = sum(len(chunks) for chunks in orphan_chunks.values())
print(f"⚠️ {total_orphans:,} chunks orphelins persistent")
print()
print("=" * 80)
print()
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Gérer les chunks orphelins (sans document parent)"
)
parser.add_argument(
"--create-documents",
action="store_true",
help="Créer les documents manquants pour les orphelins",
)
parser.add_argument(
"--delete-orphans",
action="store_true",
help="Supprimer les chunks orphelins (ATTENTION: perte de données)",
)
parser.add_argument(
"--execute",
action="store_true",
help="Exécuter l'opération (par défaut: dry-run)",
)
args = parser.parse_args()
# Fix encoding for Windows console
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 80)
print("GESTION DES CHUNKS ORPHELINS")
print("=" * 80)
print()
client = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
try:
if not client.is_ready():
print("❌ Weaviate is not ready. Ensure docker-compose is running.")
sys.exit(1)
print("✓ Weaviate is ready")
print()
# Identifier les orphelins
orphan_chunks = identify_orphan_chunks(client)
# Afficher le rapport
display_orphans_report(orphan_chunks)
if not orphan_chunks:
print("✅ Aucune action nécessaire (pas d'orphelins)")
sys.exit(0)
# Décider de l'action
if args.create_documents:
print("📋 ACTION : Créer les documents manquants")
print()
if args.execute:
print("⚠️ ATTENTION : Les documents vont être créés !")
print()
response = input("Continuer ? (oui/non) : ").strip().lower()
if response not in ["oui", "yes", "o", "y"]:
print("❌ Annulé par l'utilisateur.")
sys.exit(0)
print()
stats = create_missing_documents(client, orphan_chunks, dry_run=not args.execute)
if args.execute and stats["created"] > 0:
verify_operation(client)
elif args.delete_orphans:
print("📋 ACTION : Supprimer les chunks orphelins")
print()
total_orphans = sum(len(chunks) for chunks in orphan_chunks.values())
if args.execute:
print(f"⚠️ ATTENTION : {total_orphans:,} chunks vont être SUPPRIMÉS DÉFINITIVEMENT !")
print("⚠️ Cette opération est IRRÉVERSIBLE !")
print()
response = input("Continuer ? (oui/non) : ").strip().lower()
if response not in ["oui", "yes", "o", "y"]:
print("❌ Annulé par l'utilisateur.")
sys.exit(0)
print()
stats = delete_orphan_chunks(client, orphan_chunks, dry_run=not args.execute)
if args.execute and stats["deleted"] > 0:
verify_operation(client)
else:
# Mode liste uniquement (par défaut)
print("=" * 80)
print("💡 ACTIONS POSSIBLES")
print("=" * 80)
print()
print("Option 1 : Créer les documents manquants (recommandé)")
print(" python manage_orphan_chunks.py --create-documents --execute")
print()
print("Option 2 : Supprimer les chunks orphelins (ATTENTION: perte de données)")
print(" python manage_orphan_chunks.py --delete-orphans --execute")
print()
print("Option 3 : Ne rien faire (laisser orphelins)")
print(" Les chunks restent accessibles via recherche sémantique")
print()
finally:
client.close()
if __name__ == "__main__":
main()

View File

@@ -1,91 +0,0 @@
"""Script to display all documents from the Weaviate Document collection in table format.
Usage:
python show_works.py
"""
import weaviate
from typing import Any
from tabulate import tabulate
from datetime import datetime
def format_date(date_val: Any) -> str:
"""Format date for display.
Args:
date_val: Date value (string or datetime).
Returns:
Formatted date string.
"""
if date_val is None:
return "-"
if isinstance(date_val, str):
try:
dt = datetime.fromisoformat(date_val.replace('Z', '+00:00'))
return dt.strftime("%Y-%m-%d %H:%M")
except:
return date_val
return str(date_val)
def display_documents() -> None:
"""Connect to Weaviate and display all Document objects in table format."""
try:
# Connect to local Weaviate instance
client = weaviate.connect_to_local()
try:
# Get Document collection
document_collection = client.collections.get("Document")
# Fetch all documents
response = document_collection.query.fetch_objects(limit=1000)
if not response.objects:
print("No documents found in the collection.")
return
# Prepare data for table
table_data = []
for obj in response.objects:
props = obj.properties
# Extract nested work object
work = props.get("work", {})
work_title = work.get("title", "N/A") if isinstance(work, dict) else "N/A"
work_author = work.get("author", "N/A") if isinstance(work, dict) else "N/A"
table_data.append([
props.get("sourceId", "N/A"),
work_title,
work_author,
props.get("edition", "-"),
props.get("pages", "-"),
props.get("chunksCount", "-"),
props.get("language", "-"),
format_date(props.get("createdAt")),
])
# Display header
print(f"\n{'='*120}")
print(f"Collection Document - {len(response.objects)} document(s) trouvé(s)")
print(f"{'='*120}\n")
# Display table
headers = ["Source ID", "Work Title", "Author", "Edition", "Pages", "Chunks", "Lang", "Created At"]
print(tabulate(table_data, headers=headers, tablefmt="grid"))
print()
finally:
client.close()
except Exception as e:
print(f"Error connecting to Weaviate: {e}")
print("\nMake sure Weaviate is running:")
print(" docker compose up -d")
if __name__ == "__main__":
display_documents()

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""Test Weaviate connection from Flask context."""
import weaviate
try:
print("Tentative de connexion à Weaviate...")
client = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
print("[OK] Connexion etablie!")
print(f"[OK] Weaviate est pret: {client.is_ready()}")
# Test query
collections = client.collections.list_all()
print(f"[OK] Collections disponibles: {list(collections.keys())}")
client.close()
print("[OK] Test reussi!")
except Exception as e:
print(f"[ERREUR] {e}")
print(f"Type d'erreur: {type(e).__name__}")
import traceback
traceback.print_exc()

View File

@@ -1,441 +0,0 @@
#!/usr/bin/env python3
"""Vérification de la qualité des données Weaviate œuvre par œuvre.
Ce script analyse la cohérence entre les 4 collections (Work, Document, Chunk, Summary)
et détecte les incohérences :
- Documents sans chunks/summaries
- Chunks/summaries orphelins
- Works manquants
- Incohérences dans les nested objects
Usage:
python verify_data_quality.py
"""
import sys
from typing import Any, Dict, List, Set, Optional
from collections import defaultdict
import weaviate
from weaviate.collections import Collection
# =============================================================================
# Data Quality Checks
# =============================================================================
class DataQualityReport:
"""Rapport de qualité des données."""
def __init__(self) -> None:
self.total_documents = 0
self.total_chunks = 0
self.total_summaries = 0
self.total_works = 0
self.documents: List[Dict[str, Any]] = []
self.issues: List[str] = []
self.warnings: List[str] = []
# Tracking des œuvres uniques extraites des nested objects
self.unique_works: Dict[str, Set[str]] = defaultdict(set) # title -> set(authors)
def add_issue(self, severity: str, message: str) -> None:
"""Ajouter un problème détecté."""
if severity == "ERROR":
self.issues.append(f"{message}")
elif severity == "WARNING":
self.warnings.append(f"⚠️ {message}")
def add_document(self, doc_data: Dict[str, Any]) -> None:
"""Ajouter les données d'un document analysé."""
self.documents.append(doc_data)
def print_report(self) -> None:
"""Afficher le rapport complet."""
print("\n" + "=" * 80)
print("RAPPORT DE QUALITÉ DES DONNÉES WEAVIATE")
print("=" * 80)
# Statistiques globales
print("\n📊 STATISTIQUES GLOBALES")
print("" * 80)
print(f" • Works (collection) : {self.total_works:>6,} objets")
print(f" • Documents : {self.total_documents:>6,} objets")
print(f" • Chunks : {self.total_chunks:>6,} objets")
print(f" • Summaries : {self.total_summaries:>6,} objets")
print()
print(f" • Œuvres uniques (nested): {len(self.unique_works):>6,} détectées")
# Œuvres uniques détectées dans nested objects
if self.unique_works:
print("\n📚 ŒUVRES DÉTECTÉES (via nested objects dans Chunks)")
print("" * 80)
for i, (title, authors) in enumerate(sorted(self.unique_works.items()), 1):
authors_str = ", ".join(sorted(authors))
print(f" {i:2d}. {title}")
print(f" Auteur(s): {authors_str}")
# Analyse par document
print("\n" + "=" * 80)
print("ANALYSE DÉTAILLÉE PAR DOCUMENT")
print("=" * 80)
for i, doc in enumerate(self.documents, 1):
status = "" if doc["chunks_count"] > 0 and doc["summaries_count"] > 0 else "⚠️"
print(f"\n{status} [{i}/{len(self.documents)}] {doc['sourceId']}")
print("" * 80)
# Métadonnées Document
if doc.get("work_nested"):
work = doc["work_nested"]
print(f" Œuvre : {work.get('title', 'N/A')}")
print(f" Auteur : {work.get('author', 'N/A')}")
else:
print(f" Œuvre : {doc.get('title', 'N/A')}")
print(f" Auteur : {doc.get('author', 'N/A')}")
print(f" Édition : {doc.get('edition', 'N/A')}")
print(f" Langue : {doc.get('language', 'N/A')}")
print(f" Pages : {doc.get('pages', 0):,}")
# Collections
print()
print(f" 📦 Collections :")
print(f" • Chunks : {doc['chunks_count']:>6,} objets")
print(f" • Summaries : {doc['summaries_count']:>6,} objets")
# Work collection
if doc.get("has_work_object"):
print(f" • Work : ✅ Existe dans collection Work")
else:
print(f" • Work : ❌ MANQUANT dans collection Work")
# Cohérence nested objects
if doc.get("nested_works_consistency"):
consistency = doc["nested_works_consistency"]
if consistency["is_consistent"]:
print(f" • Cohérence nested objects : ✅ OK")
else:
print(f" • Cohérence nested objects : ⚠️ INCOHÉRENCES DÉTECTÉES")
if consistency["unique_titles"] > 1:
print(f"{consistency['unique_titles']} titres différents dans chunks:")
for title in consistency["titles"]:
print(f" - {title}")
if consistency["unique_authors"] > 1:
print(f"{consistency['unique_authors']} auteurs différents dans chunks:")
for author in consistency["authors"]:
print(f" - {author}")
# Ratios
if doc["chunks_count"] > 0:
ratio = doc["summaries_count"] / doc["chunks_count"]
print(f" 📊 Ratio Summary/Chunk : {ratio:.2f}")
if ratio < 0.5:
print(f" ⚠️ Ratio faible (< 0.5) - Peut-être des summaries manquants")
elif ratio > 3.0:
print(f" ⚠️ Ratio élevé (> 3.0) - Beaucoup de summaries pour peu de chunks")
# Problèmes spécifiques à ce document
if doc.get("issues"):
print(f"\n ⚠️ Problèmes détectés :")
for issue in doc["issues"]:
print(f"{issue}")
# Problèmes globaux
if self.issues or self.warnings:
print("\n" + "=" * 80)
print("PROBLÈMES DÉTECTÉS")
print("=" * 80)
if self.issues:
print("\n❌ ERREURS CRITIQUES :")
for issue in self.issues:
print(f" {issue}")
if self.warnings:
print("\n⚠️ AVERTISSEMENTS :")
for warning in self.warnings:
print(f" {warning}")
# Recommandations
print("\n" + "=" * 80)
print("RECOMMANDATIONS")
print("=" * 80)
if self.total_works == 0 and len(self.unique_works) > 0:
print("\n📌 Collection Work vide")
print(f"{len(self.unique_works)} œuvres uniques détectées dans nested objects")
print(f" • Recommandation : Peupler la collection Work")
print(f" • Commande : python migrate_add_work_collection.py")
print(f" • Ensuite : Créer des objets Work depuis les nested objects uniques")
# Vérifier cohérence counts
total_chunks_declared = sum(doc.get("chunksCount", 0) for doc in self.documents if "chunksCount" in doc)
if total_chunks_declared != self.total_chunks:
print(f"\n⚠️ Incohérence counts")
print(f" • Document.chunksCount total : {total_chunks_declared:,}")
print(f" • Chunks réels : {self.total_chunks:,}")
print(f" • Différence : {abs(total_chunks_declared - self.total_chunks):,}")
print("\n" + "=" * 80)
print("FIN DU RAPPORT")
print("=" * 80)
print()
def analyze_document_quality(
all_chunks: List[Any],
all_summaries: List[Any],
doc_sourceId: str,
client: weaviate.WeaviateClient,
) -> Dict[str, Any]:
"""Analyser la qualité des données pour un document spécifique.
Args:
all_chunks: All chunks from database (to filter in Python).
all_summaries: All summaries from database (to filter in Python).
doc_sourceId: Document identifier to analyze.
client: Connected Weaviate client.
Returns:
Dict containing analysis results.
"""
result: Dict[str, Any] = {
"sourceId": doc_sourceId,
"chunks_count": 0,
"summaries_count": 0,
"has_work_object": False,
"issues": [],
}
# Filtrer les chunks associés (en Python car nested objects non filtrables)
try:
doc_chunks = [
chunk for chunk in all_chunks
if chunk.properties.get("document", {}).get("sourceId") == doc_sourceId
]
result["chunks_count"] = len(doc_chunks)
# Analyser cohérence nested objects
if doc_chunks:
titles: Set[str] = set()
authors: Set[str] = set()
for chunk_obj in doc_chunks:
props = chunk_obj.properties
if "work" in props and isinstance(props["work"], dict):
work = props["work"]
if work.get("title"):
titles.add(work["title"])
if work.get("author"):
authors.add(work["author"])
result["nested_works_consistency"] = {
"titles": sorted(titles),
"authors": sorted(authors),
"unique_titles": len(titles),
"unique_authors": len(authors),
"is_consistent": len(titles) <= 1 and len(authors) <= 1,
}
# Récupérer work/author pour ce document
if titles and authors:
result["work_from_chunks"] = {
"title": list(titles)[0] if len(titles) == 1 else titles,
"author": list(authors)[0] if len(authors) == 1 else authors,
}
except Exception as e:
result["issues"].append(f"Erreur analyse chunks: {e}")
# Filtrer les summaries associés (en Python)
try:
doc_summaries = [
summary for summary in all_summaries
if summary.properties.get("document", {}).get("sourceId") == doc_sourceId
]
result["summaries_count"] = len(doc_summaries)
except Exception as e:
result["issues"].append(f"Erreur analyse summaries: {e}")
# Vérifier si Work existe
if result.get("work_from_chunks"):
work_info = result["work_from_chunks"]
if isinstance(work_info["title"], str):
try:
work_collection = client.collections.get("Work")
work_response = work_collection.query.fetch_objects(
filters=weaviate.classes.query.Filter.by_property("title").equal(work_info["title"]),
limit=1,
)
result["has_work_object"] = len(work_response.objects) > 0
except Exception as e:
result["issues"].append(f"Erreur vérification Work: {e}")
# Détection de problèmes
if result["chunks_count"] == 0:
result["issues"].append("Aucun chunk trouvé pour ce document")
if result["summaries_count"] == 0:
result["issues"].append("Aucun summary trouvé pour ce document")
if result.get("nested_works_consistency") and not result["nested_works_consistency"]["is_consistent"]:
result["issues"].append("Incohérences dans les nested objects work")
return result
def main() -> None:
"""Main entry point."""
# Fix encoding for Windows console
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 80)
print("VÉRIFICATION DE LA QUALITÉ DES DONNÉES WEAVIATE")
print("=" * 80)
print()
client = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
try:
if not client.is_ready():
print("❌ Weaviate is not ready. Ensure docker-compose is running.")
sys.exit(1)
print("✓ Weaviate is ready")
print("✓ Starting data quality analysis...")
print()
report = DataQualityReport()
# Récupérer counts globaux
try:
work_coll = client.collections.get("Work")
work_result = work_coll.aggregate.over_all(total_count=True)
report.total_works = work_result.total_count
except Exception as e:
report.add_issue("ERROR", f"Cannot count Work objects: {e}")
try:
chunk_coll = client.collections.get("Chunk")
chunk_result = chunk_coll.aggregate.over_all(total_count=True)
report.total_chunks = chunk_result.total_count
except Exception as e:
report.add_issue("ERROR", f"Cannot count Chunk objects: {e}")
try:
summary_coll = client.collections.get("Summary")
summary_result = summary_coll.aggregate.over_all(total_count=True)
report.total_summaries = summary_result.total_count
except Exception as e:
report.add_issue("ERROR", f"Cannot count Summary objects: {e}")
# Récupérer TOUS les chunks et summaries en une fois
# (car nested objects non filtrables via API Weaviate)
print("Loading all chunks and summaries into memory...")
all_chunks: List[Any] = []
all_summaries: List[Any] = []
try:
chunk_coll = client.collections.get("Chunk")
chunks_response = chunk_coll.query.fetch_objects(
limit=10000, # Haute limite pour gros corpus
# Note: nested objects (work, document) sont retournés automatiquement
)
all_chunks = chunks_response.objects
print(f" ✓ Loaded {len(all_chunks)} chunks")
except Exception as e:
report.add_issue("ERROR", f"Cannot fetch all chunks: {e}")
try:
summary_coll = client.collections.get("Summary")
summaries_response = summary_coll.query.fetch_objects(
limit=10000,
# Note: nested objects (document) sont retournés automatiquement
)
all_summaries = summaries_response.objects
print(f" ✓ Loaded {len(all_summaries)} summaries")
except Exception as e:
report.add_issue("ERROR", f"Cannot fetch all summaries: {e}")
print()
# Récupérer tous les documents
try:
doc_collection = client.collections.get("Document")
docs_response = doc_collection.query.fetch_objects(
limit=1000,
return_properties=["sourceId", "title", "author", "edition", "language", "pages", "chunksCount", "work"],
)
report.total_documents = len(docs_response.objects)
print(f"Analyzing {report.total_documents} documents...")
print()
for doc_obj in docs_response.objects:
props = doc_obj.properties
doc_sourceId = props.get("sourceId", "unknown")
print(f" • Analyzing {doc_sourceId}...", end=" ")
# Analyser ce document (avec filtrage Python)
analysis = analyze_document_quality(all_chunks, all_summaries, doc_sourceId, client)
# Merger props Document avec analysis
analysis.update({
"title": props.get("title"),
"author": props.get("author"),
"edition": props.get("edition"),
"language": props.get("language"),
"pages": props.get("pages", 0),
"chunksCount": props.get("chunksCount", 0),
"work_nested": props.get("work"),
})
# Collecter œuvres uniques
if analysis.get("work_from_chunks"):
work_info = analysis["work_from_chunks"]
if isinstance(work_info["title"], str) and isinstance(work_info["author"], str):
report.unique_works[work_info["title"]].add(work_info["author"])
report.add_document(analysis)
# Feedback
if analysis["chunks_count"] > 0:
print(f"✓ ({analysis['chunks_count']} chunks, {analysis['summaries_count']} summaries)")
else:
print("⚠️ (no chunks)")
except Exception as e:
report.add_issue("ERROR", f"Cannot fetch documents: {e}")
# Vérifications globales
if report.total_works == 0 and report.total_chunks > 0:
report.add_issue("WARNING", f"Work collection is empty but {report.total_chunks:,} chunks exist")
if report.total_documents == 0 and report.total_chunks > 0:
report.add_issue("WARNING", f"No documents but {report.total_chunks:,} chunks exist (orphan chunks)")
# Afficher le rapport
report.print_report()
finally:
client.close()
if __name__ == "__main__":
main()

View File

@@ -1,185 +0,0 @@
#!/usr/bin/env python3
"""Verify vector index configuration for Chunk and Summary collections.
This script checks if the dynamic index with RQ is properly configured
for vectorized collections. It displays:
- Index type (flat, hnsw, or dynamic)
- Quantization status (RQ enabled/disabled)
- Distance metric
- Dynamic threshold (if applicable)
Usage:
python verify_vector_index.py
"""
import sys
from typing import Any, Dict
import weaviate
def check_collection_index(client: weaviate.WeaviateClient, collection_name: str) -> None:
"""Check and display vector index configuration for a collection.
Args:
client: Connected Weaviate client.
collection_name: Name of the collection to check.
"""
try:
collections = client.collections.list_all()
if collection_name not in collections:
print(f" ❌ Collection '{collection_name}' not found")
return
config = collections[collection_name]
print(f"\n📦 {collection_name}")
print("" * 80)
# Check vectorizer
vectorizer_str: str = str(config.vectorizer)
if "text2vec" in vectorizer_str.lower():
print(" ✓ Vectorizer: text2vec-transformers")
elif "none" in vectorizer_str.lower():
print(" Vectorizer: NONE (metadata collection)")
return
else:
print(f" ⚠ Vectorizer: {vectorizer_str}")
# Try to get vector index config (API structure varies)
# Access via config object properties
config_dict: Dict[str, Any] = {}
# Try different API paths to get config info
if hasattr(config, 'vector_index_config'):
vector_config = config.vector_index_config
config_dict['vector_config'] = str(vector_config)
# Check for specific attributes
if hasattr(vector_config, 'quantizer'):
config_dict['quantizer'] = str(vector_config.quantizer)
if hasattr(vector_config, 'distance_metric'):
config_dict['distance_metric'] = str(vector_config.distance_metric)
# Display available info
if config_dict:
print(f" • Configuration détectée:")
for key, value in config_dict.items():
print(f" - {key}: {value}")
# Simplified detection based on config representation
config_full_str = str(config)
# Detect index type
if "dynamic" in config_full_str.lower():
print(" • Index Type: DYNAMIC")
elif "hnsw" in config_full_str.lower():
print(" • Index Type: HNSW")
elif "flat" in config_full_str.lower():
print(" • Index Type: FLAT")
else:
print(" • Index Type: UNKNOWN (default HNSW probable)")
# Check for RQ
if "rq" in config_full_str.lower() or "quantizer" in config_full_str.lower():
print(" ✓ RQ (Rotational Quantization): Probablement ENABLED")
else:
print(" ⚠ RQ (Rotational Quantization): NOT DETECTED (ou désactivé)")
# Check distance metric
if "cosine" in config_full_str.lower():
print(" • Distance Metric: COSINE (détecté)")
elif "dot" in config_full_str.lower():
print(" • Distance Metric: DOT PRODUCT (détecté)")
elif "l2" in config_full_str.lower():
print(" • Distance Metric: L2 SQUARED (détecté)")
print("\n Interpretation:")
if "dynamic" in config_full_str.lower() and ("rq" in config_full_str.lower() or "quantizer" in config_full_str.lower()):
print(" ✅ OPTIMIZED: Dynamic index with RQ enabled")
print(" → Memory savings: ~75% at scale")
print(" → Auto-switches from flat to HNSW at threshold")
elif "hnsw" in config_full_str.lower():
if "rq" in config_full_str.lower() or "quantizer" in config_full_str.lower():
print(" ✅ HNSW with RQ: Good for large collections")
else:
print(" ⚠ HNSW without RQ: Consider enabling RQ for memory savings")
elif "flat" in config_full_str.lower():
print(" FLAT index: Good for small collections (<100k vectors)")
else:
print(" ⚠ Unknown index configuration (probably default HNSW)")
print(" → Collections créées sans config explicite utilisent HNSW par défaut")
except Exception as e:
print(f" ❌ Error checking {collection_name}: {e}")
def main() -> None:
"""Main entry point."""
# Fix encoding for Windows console
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 80)
print("VÉRIFICATION DES INDEX VECTORIELS WEAVIATE")
print("=" * 80)
client: weaviate.WeaviateClient = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
try:
# Check if Weaviate is ready
if not client.is_ready():
print("\n❌ Weaviate is not ready. Ensure docker-compose is running.")
return
print("\n✓ Weaviate is ready")
# Get all collections
collections = client.collections.list_all()
print(f"✓ Found {len(collections)} collections: {sorted(collections.keys())}")
# Check vectorized collections (Chunk and Summary)
print("\n" + "=" * 80)
print("COLLECTIONS VECTORISÉES")
print("=" * 80)
check_collection_index(client, "Chunk")
check_collection_index(client, "Summary")
# Check non-vectorized collections (for reference)
print("\n" + "=" * 80)
print("COLLECTIONS MÉTADONNÉES (Non vectorisées)")
print("=" * 80)
check_collection_index(client, "Work")
check_collection_index(client, "Document")
print("\n" + "=" * 80)
print("VÉRIFICATION TERMINÉE")
print("=" * 80)
# Count objects in each collection
print("\n📊 STATISTIQUES:")
for name in ["Work", "Document", "Chunk", "Summary"]:
if name in collections:
try:
coll = client.collections.get(name)
# Simple count using aggregate (works for all collections)
result = coll.aggregate.over_all(total_count=True)
count = result.total_count
print(f"{name:<12} {count:>8,} objets")
except Exception as e:
print(f"{name:<12} Error: {e}")
finally:
client.close()
print("\n✓ Connexion fermée\n")
if __name__ == "__main__":
main()

View File

@@ -1,229 +0,0 @@
"""Script pour restaurer uniquement les chunks manquants.
Ce script:
1. Récupère tous les chunks déjà présents dans Weaviate
2. Compare avec le backup pour identifier les chunks manquants
3. Importe uniquement les chunks manquants
Usage:
python restore_remaining_chunks.py backup_migration_20260105_174349
"""
import json
import logging
import re
import sys
import time
from pathlib import Path
from typing import Set
import weaviate
# Configuration logging
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def fix_date_format(value):
"""Convertit les dates ISO8601 en RFC3339 (remplace espace par T)."""
if isinstance(value, str) and re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', value):
return value.replace(' ', 'T', 1)
return value
def fix_dates_in_object(obj):
"""Parcourt récursivement un objet et fixe les formats de date."""
if isinstance(obj, dict):
return {k: fix_dates_in_object(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [fix_dates_in_object(item) for item in obj]
else:
return fix_date_format(obj)
def get_existing_chunk_texts(client: weaviate.WeaviateClient) -> Set[str]:
"""Récupère les textes de tous les chunks existants pour comparaison.
On utilise les premiers 100 caractères du texte comme clé unique.
"""
logger.info("Récupération des chunks existants...")
chunk_collection = client.collections.get("Chunk")
existing_texts = set()
cursor = None
batch_size = 1000
while True:
if cursor:
response = chunk_collection.query.fetch_objects(
limit=batch_size,
after=cursor
)
else:
response = chunk_collection.query.fetch_objects(limit=batch_size)
if not response.objects:
break
for obj in response.objects:
text = obj.properties.get("text", "")
# Utiliser les 100 premiers caractères comme clé unique
text_key = text[:100] if text else ""
existing_texts.add(text_key)
if len(response.objects) < batch_size:
break
cursor = response.objects[-1].uuid
logger.info(f"{len(existing_texts)} chunks existants récupérés")
return existing_texts
def import_missing_chunks(
client: weaviate.WeaviateClient,
backup_file: Path,
existing_texts: Set[str]
) -> int:
"""Importe uniquement les chunks manquants."""
logger.info(f"Chargement du backup depuis {backup_file}...")
if not backup_file.exists():
logger.error(f" ✗ Fichier {backup_file} introuvable")
return 0
try:
with open(backup_file, "r", encoding="utf-8") as f:
objects = json.load(f)
logger.info(f"{len(objects)} chunks dans le backup")
# Filtrer les chunks manquants
missing_chunks = []
for obj in objects:
text = obj["properties"].get("text", "")
text_key = text[:100] if text else ""
if text_key not in existing_texts:
missing_chunks.append(obj)
logger.info(f"{len(missing_chunks)} chunks manquants à restaurer")
if not missing_chunks:
logger.info(" ✓ Aucun chunk manquant !")
return 0
# Préparer les objets pour l'insertion
collection = client.collections.get("Chunk")
objects_to_insert = []
for obj in missing_chunks:
props = obj["properties"]
# Ajouter le champ summary vide
props["summary"] = ""
# Fixer les formats de date
props = fix_dates_in_object(props)
objects_to_insert.append(props)
# Insertion par batch
batch_size = 20 # Petit batch pour éviter OOM
total_inserted = 0
logger.info("\nInsertion des chunks manquants...")
for i in range(0, len(objects_to_insert), batch_size):
batch = objects_to_insert[i:i + batch_size]
try:
collection.data.insert_many(batch)
total_inserted += len(batch)
if (i // batch_size + 1) % 10 == 0:
logger.info(f"{total_inserted}/{len(objects_to_insert)} objets insérés...")
# Pause entre batches pour éviter surcharge mémoire
time.sleep(0.1)
except Exception as e:
logger.error(f" ✗ Erreur batch {i//batch_size + 1}: {e}")
# En cas d'erreur, attendre plus longtemps et continuer
time.sleep(5)
logger.info(f"\n{total_inserted} chunks manquants importés")
return total_inserted
except Exception as e:
logger.error(f" ✗ Erreur lors de l'import: {e}")
return 0
def main():
if len(sys.argv) < 2:
print("Usage: python restore_remaining_chunks.py <backup_directory>")
sys.exit(1)
backup_dir = Path(sys.argv[1])
if not backup_dir.exists():
logger.error(f"Backup directory '{backup_dir}' does not exist")
sys.exit(1)
logger.info("=" * 80)
logger.info(f"RESTORATION DES CHUNKS MANQUANTS DEPUIS {backup_dir}")
logger.info("=" * 80)
# Connexion à Weaviate
logger.info("\nConnexion à Weaviate...")
try:
client = weaviate.connect_to_local(
host="localhost",
port=8080,
grpc_port=50051,
)
logger.info(" ✓ Connexion établie")
except Exception as e:
logger.error(f" ✗ Erreur de connexion: {e}")
sys.exit(1)
try:
# Étape 1: Récupérer les chunks existants
existing_texts = get_existing_chunk_texts(client)
# Étape 2: Importer les chunks manquants
backup_file = backup_dir / "chunk_backup.json"
total_imported = import_missing_chunks(client, backup_file, existing_texts)
# Étape 3: Vérification finale
logger.info("\nVérification finale...")
chunk_collection = client.collections.get("Chunk")
result = chunk_collection.aggregate.over_all()
final_count = result.total_count
logger.info(f" ✓ Total de chunks dans Weaviate: {final_count}")
logger.info("\n" + "=" * 80)
logger.info("RESTORATION DES CHUNKS MANQUANTS TERMINÉE !")
logger.info("=" * 80)
logger.info(f"✓ Chunks importés: {total_imported}")
logger.info(f"✓ Total final: {final_count}/5246")
logger.info("=" * 80)
finally:
client.close()
logger.info("\n✓ Connexion fermée")
if __name__ == "__main__":
# Fix encoding for Windows
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
main()

View File

@@ -1,19 +0,0 @@
@echo off
echo ========================================
echo REPRISE GENERATION RESUMES
echo ========================================
echo.
cd /d "%~dp0"
echo Chunks deja traites:
python -c "import json; p=json.load(open('summary_generation_progress.json')); print(f' -> {p[\"total_processed\"]} chunks traites')" 2>nul || echo -> Aucun chunk traite
echo.
echo Lancement de la generation...
echo (Ctrl+C pour arreter - progression sauvegardee)
echo.
python ..\..\utils\generate_all_summaries.py
pause

View File

@@ -1,32 +0,0 @@
"""Récupère des exemples de résumés générés."""
import weaviate
client = weaviate.connect_to_local()
chunk_col = client.collections.get('Chunk')
# Récupérer les 10 premiers chunks avec résumé
response = chunk_col.query.fetch_objects(limit=100)
summaries_found = 0
for obj in response.objects:
summary = obj.properties.get('summary', '')
if summary and summary != '':
text = obj.properties.get('text', '')
work = obj.properties.get('work', {})
print("=" * 80)
print(f"WORK: {work.get('title', 'N/A')} - {work.get('author', 'N/A')}")
print("=" * 80)
print(f"\nTEXTE ORIGINAL ({len(text)} chars):")
print(text[:300] + "..." if len(text) > 300 else text)
print(f"\nRÉSUMÉ GÉNÉRÉ ({len(summary)} chars):")
print(summary)
print("\n")
summaries_found += 1
if summaries_found >= 5:
break
client.close()
print(f"\n{summaries_found} exemples affichés")

View File

@@ -1,291 +0,0 @@
"""Interface de recherche optimisée utilisant Summary comme collection primaire.
Cette implémentation utilise la collection Summary comme point d'entrée principal
pour la recherche sémantique, car elle offre 90% de visibilité des documents riches
vs 10% pour la recherche directe dans Chunks (domination Peirce).
Usage:
python search_summary_interface.py "What is pragmatism?"
python search_summary_interface.py "Can virtue be taught?"
"""
import sys
import io
import argparse
from typing import List, Dict, Any
import weaviate
import weaviate.classes.query as wvq
# Fix Windows encoding
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
def search_summaries(
query: str,
limit: int = 10,
min_similarity: float = 0.65
) -> List[Dict[str, Any]]:
"""Recherche sémantique dans la collection Summary.
Args:
query: Question de l'utilisateur
limit: Nombre maximum de résultats
min_similarity: Seuil de similarité minimum (0-1)
Returns:
Liste de dictionnaires contenant les résultats avec métadonnées
"""
client = weaviate.connect_to_local()
try:
summaries = client.collections.get("Summary")
# Recherche sémantique
results = summaries.query.near_text(
query=query,
limit=limit,
return_metadata=wvq.MetadataQuery(distance=True)
)
# Formater les résultats
formatted_results = []
for obj in results.objects:
similarity = 1 - obj.metadata.distance
# Filtrer par seuil de similarité
if similarity < min_similarity:
continue
props = obj.properties
result = {
"similarity": similarity,
"document": props["document"]["sourceId"],
"title": props["title"],
"summary": props.get("text", ""),
"concepts": props.get("concepts", []),
"section_path": props.get("sectionPath", ""),
"chunks_count": props.get("chunksCount", 0),
"author": props["document"].get("author", ""),
"year": props["document"].get("year", 0),
}
formatted_results.append(result)
return formatted_results
finally:
client.close()
def display_results(query: str, results: List[Dict[str, Any]]) -> None:
"""Affiche les résultats de recherche de manière formatée.
Args:
query: Question originale
results: Liste des résultats de search_summaries()
"""
print("=" * 100)
print(f"RECHERCHE: '{query}'")
print("=" * 100)
print()
if not results:
print("❌ Aucun résultat trouvé")
print()
return
print(f"{len(results)} résultat(s) trouvé(s)")
print()
for i, result in enumerate(results, 1):
# Icône par document
doc_id = result["document"].lower()
if "tiercelin" in doc_id:
icon = "🟡"
doc_name = "Tiercelin"
elif "platon" in doc_id or "menon" in doc_id:
icon = "🟢"
doc_name = "Platon"
elif "haugeland" in doc_id:
icon = "🟣"
doc_name = "Haugeland"
elif "logique" in doc_id:
icon = "🔵"
doc_name = "Logique de la science"
else:
icon = ""
doc_name = "Peirce"
similarity_pct = result["similarity"] * 100
print(f"[{i}] {icon} {doc_name} - Similarité: {result['similarity']:.3f} ({similarity_pct:.1f}%)")
print(f" Titre: {result['title']}")
# Afficher auteur/année si disponible
if result["author"]:
author_info = f"{result['author']}"
if result["year"]:
author_info += f" ({result['year']})"
print(f" Auteur: {author_info}")
# Concepts clés
if result["concepts"]:
concepts_str = ", ".join(result["concepts"][:5]) # Top 5 concepts
if len(result["concepts"]) > 5:
concepts_str += f" (+{len(result['concepts']) - 5} autres)"
print(f" Concepts: {concepts_str}")
# Résumé
summary = result["summary"]
if len(summary) > 300:
summary = summary[:297] + "..."
if summary:
print(f" Résumé: {summary}")
else:
print(f" Résumé: [Titre de section sans résumé]")
# Chunks disponibles
if result["chunks_count"] > 0:
print(f" 📄 {result['chunks_count']} chunk(s) disponible(s) pour lecture détaillée")
print()
print("-" * 100)
print()
def get_chunks_for_section(
document_id: str,
section_path: str,
limit: int = 5
) -> List[Dict[str, Any]]:
"""Récupère les chunks détaillés d'une section spécifique.
Utilisé quand l'utilisateur veut lire le contenu détaillé d'un résumé.
Args:
document_id: ID du document (sourceId)
section_path: Chemin de la section
limit: Nombre maximum de chunks
Returns:
Liste de chunks avec texte complet
"""
client = weaviate.connect_to_local()
try:
chunks = client.collections.get("Chunk")
# Récupérer tous les chunks (pas de filtrage nested object possible)
all_chunks = list(chunks.iterator())
# Filtrer en Python
section_chunks = [
c for c in all_chunks
if c.properties.get("document", {}).get("sourceId") == document_id
and c.properties.get("sectionPath", "").startswith(section_path)
]
# Trier par orderIndex si disponible
section_chunks.sort(
key=lambda c: c.properties.get("orderIndex", 0)
)
# Limiter
section_chunks = section_chunks[:limit]
# Formater
formatted_chunks = []
for chunk in section_chunks:
props = chunk.properties
formatted_chunks.append({
"text": props.get("text", ""),
"section": props.get("sectionPath", ""),
"chapter": props.get("chapterTitle", ""),
"keywords": props.get("keywords", []),
"order": props.get("orderIndex", 0),
})
return formatted_chunks
finally:
client.close()
def interactive_mode():
"""Mode interactif pour recherche continue."""
print("=" * 100)
print("INTERFACE DE RECHERCHE RAG - Collection Summary")
print("=" * 100)
print()
print("Mode: Summary-first (90% de visibilité démontrée)")
print("Tapez 'quit' pour quitter")
print()
while True:
try:
query = input("Votre question: ").strip()
if query.lower() in ["quit", "exit", "q"]:
print("Au revoir!")
break
if not query:
continue
print()
results = search_summaries(query, limit=10, min_similarity=0.65)
display_results(query, results)
except KeyboardInterrupt:
print("\nAu revoir!")
break
except Exception as e:
print(f"❌ Erreur: {e}")
print()
def main():
"""Point d'entrée principal."""
parser = argparse.ArgumentParser(
description="Recherche sémantique optimisée via Summary collection"
)
parser.add_argument(
"query",
nargs="?",
help="Question de recherche (optionnel - lance mode interactif si absent)"
)
parser.add_argument(
"-n", "--limit",
type=int,
default=10,
help="Nombre maximum de résultats (défaut: 10)"
)
parser.add_argument(
"-s", "--min-similarity",
type=float,
default=0.65,
help="Seuil de similarité minimum 0-1 (défaut: 0.65)"
)
args = parser.parse_args()
if args.query:
# Mode requête unique
results = search_summaries(
args.query,
limit=args.limit,
min_similarity=args.min_similarity
)
display_results(args.query, results)
else:
# Mode interactif
interactive_mode()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,237 @@
/**
* Puppeteer test for /test-chat-backend page
* Tests the RAG chat functionality with streaming SSE responses
*
* Usage: node test_chat_backend.js
*/
const puppeteer = require('puppeteer');
const BASE_URL = 'http://localhost:5000';
const TIMEOUT = 120000; // 2 minutes for LLM response
async function testChatBackend() {
console.log('=== Test Chat Backend RAG ===\n');
let browser;
try {
// Launch browser
console.log('1. Launching browser...');
browser = await puppeteer.launch({
headless: false, // Set to true for CI
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
page.setDefaultTimeout(TIMEOUT);
// Enable console logging from the page
page.on('console', msg => {
if (msg.type() === 'error') {
console.log(' [Browser Error]', msg.text());
}
});
// Navigate to test page
console.log('2. Navigating to /test-chat-backend...');
await page.goto(`${BASE_URL}/test-chat-backend`, {
waitUntil: 'networkidle0',
timeout: 30000
});
console.log(' OK - Page loaded');
// Fill in the question
console.log('3. Filling in the form...');
const question = "What is a Turing machine?";
await page.evaluate((q) => {
document.getElementById('question').value = q;
}, question);
console.log(` Question: "${question}"`);
// Select provider (Mistral by default)
const provider = 'mistral';
await page.select('#provider', provider);
console.log(` Provider: ${provider}`);
// Select model
const model = 'mistral-small-latest';
await page.select('#model', model);
console.log(` Model: ${model}`);
// Set limit
await page.evaluate(() => {
document.getElementById('limit').value = '3';
});
console.log(' Limit: 3');
// Click send button
console.log('4. Sending question...');
await page.click('#sendBtn');
// Wait for output section to appear
await page.waitForSelector('#output[style*="block"]', { timeout: 10000 });
console.log(' OK - Output section visible');
// Wait for session ID to appear in log
console.log('5. Waiting for session creation...');
await page.waitForFunction(() => {
const log = document.getElementById('log');
return log && log.textContent.includes('Session:');
}, { timeout: 15000 });
const sessionInfo = await page.evaluate(() => {
return document.getElementById('log').textContent;
});
console.log(` ${sessionInfo.trim()}`);
// Wait for context (RAG results) or error
console.log('6. Waiting for RAG context...');
try {
await page.waitForSelector('#contextSection[style*="block"]', { timeout: 30000 });
const contextCount = await page.evaluate(() => {
const items = document.querySelectorAll('.context-item');
return items.length;
});
console.log(` OK - Received ${contextCount} context chunks`);
// Get context details
const contexts = await page.evaluate(() => {
const items = document.querySelectorAll('.context-item');
return Array.from(items).map(item => {
const text = item.textContent;
const match = text.match(/Passage (\d+).*?(\d+)%.*?-\s*([^-]+)\s*-\s*([^\n]+)/);
if (match) {
return {
passage: match[1],
similarity: match[2],
author: match[3].trim(),
work: match[4].trim()
};
}
return { raw: text.substring(0, 100) };
});
});
contexts.forEach(ctx => {
if (ctx.similarity) {
console.log(` - Passage ${ctx.passage}: ${ctx.similarity}% - ${ctx.author} - ${ctx.work}`);
}
});
} catch (e) {
// Check if there's an error
const hasError = await page.evaluate(() => {
const log = document.getElementById('log');
return log && log.textContent.includes('status-error');
});
if (hasError) {
const errorMsg = await page.evaluate(() => {
return document.getElementById('log').textContent;
});
console.log(` ERROR: ${errorMsg}`);
throw new Error(`Chat failed: ${errorMsg}`);
}
console.log(' WARNING: Context section not shown (might be empty results)');
}
// Wait for response streaming
console.log('7. Waiting for LLM response...');
try {
await page.waitForSelector('#responseSection[style*="block"]', { timeout: 60000 });
console.log(' OK - Response section visible');
// Wait for streaming to complete
await page.waitForFunction(() => {
const log = document.getElementById('log');
return log && (log.textContent.includes('Terminé') || log.textContent.includes('error'));
}, { timeout: 90000 });
// Get final status
const finalStatus = await page.evaluate(() => {
return document.getElementById('log').textContent;
});
if (finalStatus.includes('Terminé')) {
console.log(' OK - Response complete');
} else {
console.log(` Status: ${finalStatus}`);
}
// Get response length
const responseLength = await page.evaluate(() => {
const response = document.getElementById('response');
return response ? response.textContent.length : 0;
});
console.log(` Response length: ${responseLength} characters`);
// Get first 200 chars of response
const responsePreview = await page.evaluate(() => {
const response = document.getElementById('response');
return response ? response.textContent.substring(0, 200) : '';
});
console.log(` Preview: "${responsePreview}..."`);
} catch (e) {
const errorMsg = await page.evaluate(() => {
return document.getElementById('log')?.textContent || 'Unknown error';
});
console.log(` ERROR waiting for response: ${errorMsg}`);
throw e;
}
// Final verification
console.log('\n8. Final verification...');
const results = await page.evaluate(() => {
return {
hasContext: document.getElementById('contextSection').style.display !== 'none',
hasResponse: document.getElementById('responseSection').style.display !== 'none',
contextItems: document.querySelectorAll('.context-item').length,
responseLength: document.getElementById('response')?.textContent?.length || 0,
status: document.getElementById('log')?.textContent || ''
};
});
console.log(` Context shown: ${results.hasContext}`);
console.log(` Context items: ${results.contextItems}`);
console.log(` Response shown: ${results.hasResponse}`);
console.log(` Response length: ${results.responseLength}`);
console.log(` Final status: ${results.status.trim()}`);
// Determine test result
const success = results.hasResponse && results.responseLength > 100 && results.status.includes('Terminé');
console.log('\n' + '='.repeat(50));
if (success) {
console.log('TEST PASSED - Chat backend working correctly');
} else {
console.log('TEST FAILED - Check the results above');
}
console.log('='.repeat(50));
// Keep browser open for 5 seconds to see result
await new Promise(resolve => setTimeout(resolve, 5000));
return success;
} catch (error) {
console.error('\nTEST ERROR:', error.message);
return false;
} finally {
if (browser) {
await browser.close();
}
}
}
// Run test
testChatBackend()
.then(success => {
process.exit(success ? 0 : 1);
})
.catch(err => {
console.error('Unexpected error:', err);
process.exit(1);
});