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:
@@ -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 ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -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 ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <style> (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 <script> (après ligne ~732)
|
||||
- Déclarer variables globales :
|
||||
- availableWorks: Array<Work> (liste complète)
|
||||
- selectedWorks: Array<string> (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 => 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 (< 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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 ✓**
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
237
generations/library_rag/test_chat_backend.js
Normal file
237
generations/library_rag/test_chat_backend.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user