feat: Add vectorized summary field and migration tools
- Add 'summary' field to Chunk collection (vectorized with text2vec) - Migrate from Dynamic index to HNSW + RQ for both Chunk and Summary - Add LLM summarizer module (utils/llm_summarizer.py) - Add migration scripts (migrate_add_summary.py, restore_*.py) - Add summary generation utilities and progress tracking - Add testing and cleaning tools (outils_test_and_cleaning/) - Add comprehensive documentation (ANALYSE_*.md, guides) - Remove obsolete files (linear_config.py, old test files) - Update .gitignore to exclude backups and temp files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utils package for Library RAG."""
|
||||
288
utils/generate_all_summaries.py
Normal file
288
utils/generate_all_summaries.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""Script pour générer des résumés pour TOUS les chunks sans résumé.
|
||||
|
||||
Ce script génère des résumés densifiés pour tous les chunks de la base Weaviate
|
||||
qui n'ont pas encore de résumé (summary="").
|
||||
|
||||
Usage:
|
||||
python utils/generate_all_summaries.py
|
||||
|
||||
Fonctionnalités:
|
||||
- Reprend automatiquement là où il s'est arrêté (peut être interrompu)
|
||||
- Affiche progression en temps réel
|
||||
- Estimation du temps restant
|
||||
- Logging détaillé
|
||||
- Gestion des erreurs avec retry
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import ollama
|
||||
import weaviate
|
||||
from tqdm import tqdm
|
||||
|
||||
# Configuration
|
||||
OLLAMA_MODEL = "qwen2.5:7b"
|
||||
MAX_RETRIES = 3
|
||||
PROGRESS_FILE = Path("summary_generation_progress.json")
|
||||
|
||||
# Configuration logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("generate_all_summaries.log", encoding="utf-8")
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_progress() -> Dict[str, Any]:
|
||||
"""Charge la progression depuis le fichier."""
|
||||
if PROGRESS_FILE.exists():
|
||||
with open(PROGRESS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {"processed_uuids": [], "last_update": None, "total_processed": 0}
|
||||
|
||||
|
||||
def save_progress(progress: Dict[str, Any]) -> None:
|
||||
"""Sauvegarde la progression."""
|
||||
progress["last_update"] = datetime.now().isoformat()
|
||||
with open(PROGRESS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(progress, f, indent=2)
|
||||
|
||||
|
||||
def generate_summary(chunk_text: str) -> str:
|
||||
"""Génère un résumé dense avec Ollama."""
|
||||
prompt = f"""TEXTE À RÉSUMER:
|
||||
{chunk_text}
|
||||
|
||||
CONSIGNES STRICTES:
|
||||
- Résumé direct de 100-150 mots maximum
|
||||
- INTERDIT: formulations méta ("Ce passage souligne...", "L'auteur affirme...", "Peirce dit que...")
|
||||
- Reformule les idées au style direct et impersonnel
|
||||
- Densité conceptuelle maximale: chaque mot compte
|
||||
- Conserve TOUS les concepts, termes techniques et noms propres
|
||||
- Structure: thèse → arguments → implications
|
||||
- Aucune perte d'information philosophique
|
||||
|
||||
RÉSUMÉ DENSE:"""
|
||||
|
||||
response = ollama.generate(
|
||||
model=OLLAMA_MODEL,
|
||||
prompt=prompt,
|
||||
options={
|
||||
"temperature": 0.2,
|
||||
"num_predict": 250,
|
||||
}
|
||||
)
|
||||
return response["response"].strip()
|
||||
|
||||
|
||||
def get_chunks_without_summary(client: weaviate.WeaviateClient) -> List[Dict[str, Any]]:
|
||||
"""Récupère tous les chunks sans résumé."""
|
||||
logger.info("Récupération des chunks sans résumé...")
|
||||
|
||||
chunk_collection = client.collections.get("Chunk")
|
||||
all_chunks = []
|
||||
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:
|
||||
summary = obj.properties.get("summary", "")
|
||||
if not summary: # Pas de résumé ou résumé vide
|
||||
all_chunks.append({
|
||||
"uuid": str(obj.uuid),
|
||||
"text": obj.properties.get("text", ""),
|
||||
"work": obj.properties.get("work", {}).get("title", "Unknown"),
|
||||
"order": obj.properties.get("orderIndex", 0)
|
||||
})
|
||||
|
||||
if len(response.objects) < batch_size:
|
||||
break
|
||||
|
||||
cursor = response.objects[-1].uuid
|
||||
|
||||
logger.info(f" ✓ {len(all_chunks)} chunks sans résumé trouvés")
|
||||
return all_chunks
|
||||
|
||||
|
||||
def process_all_chunks(client: weaviate.WeaviateClient) -> None:
|
||||
"""Traite tous les chunks sans résumé."""
|
||||
|
||||
# Charger la progression
|
||||
progress = load_progress()
|
||||
processed_set = set(progress["processed_uuids"])
|
||||
|
||||
if processed_set:
|
||||
logger.info(f"Reprise: {len(processed_set)} chunks déjà traités")
|
||||
|
||||
# Récupérer les chunks
|
||||
all_chunks = get_chunks_without_summary(client)
|
||||
|
||||
# Filtrer ceux déjà traités
|
||||
chunks_to_process = [c for c in all_chunks if c["uuid"] not in processed_set]
|
||||
|
||||
if not chunks_to_process:
|
||||
logger.info("✓ Tous les chunks ont déjà un résumé !")
|
||||
return
|
||||
|
||||
logger.info(f"→ {len(chunks_to_process)} chunks à traiter")
|
||||
|
||||
# Statistiques
|
||||
successful = 0
|
||||
failed = 0
|
||||
start_time = time.time()
|
||||
|
||||
chunk_collection = client.collections.get("Chunk")
|
||||
|
||||
# Barre de progression
|
||||
pbar = tqdm(chunks_to_process, desc="Génération résumés", unit="chunk")
|
||||
|
||||
for i, chunk in enumerate(pbar, 1):
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < MAX_RETRIES:
|
||||
try:
|
||||
# Générer le résumé
|
||||
summary = generate_summary(chunk["text"])
|
||||
|
||||
# Mettre à jour dans Weaviate
|
||||
chunk_collection.data.update(
|
||||
uuid=chunk["uuid"],
|
||||
properties={"summary": summary}
|
||||
)
|
||||
|
||||
# Marquer comme traité
|
||||
processed_set.add(chunk["uuid"])
|
||||
successful += 1
|
||||
|
||||
# Mettre à jour barre de progression
|
||||
elapsed = time.time() - start_time
|
||||
avg_time_per_chunk = elapsed / successful if successful > 0 else 0
|
||||
remaining_chunks = len(chunks_to_process) - i
|
||||
eta_seconds = avg_time_per_chunk * remaining_chunks
|
||||
eta = timedelta(seconds=int(eta_seconds))
|
||||
|
||||
pbar.set_postfix({
|
||||
"OK": successful,
|
||||
"FAIL": failed,
|
||||
"ETA": str(eta)
|
||||
})
|
||||
|
||||
# Sauvegarder la progression tous les 10 chunks
|
||||
if successful % 10 == 0:
|
||||
progress["processed_uuids"] = list(processed_set)
|
||||
progress["total_processed"] = successful
|
||||
save_progress(progress)
|
||||
|
||||
break # Succès, passer au suivant
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("\n⚠ Interruption utilisateur")
|
||||
progress["processed_uuids"] = list(processed_set)
|
||||
progress["total_processed"] = successful
|
||||
save_progress(progress)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
logger.error(f"Erreur chunk {chunk['uuid']} (tentative {retry_count}/{MAX_RETRIES}): {e}")
|
||||
|
||||
if retry_count < MAX_RETRIES:
|
||||
time.sleep(2)
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(f"✗ Échec définitif pour chunk {chunk['uuid']}")
|
||||
|
||||
# Sauvegarder la progression finale
|
||||
progress["processed_uuids"] = list(processed_set)
|
||||
progress["total_processed"] = successful
|
||||
save_progress(progress)
|
||||
|
||||
# Résumé final
|
||||
total_time = time.time() - start_time
|
||||
avg_time = total_time / successful if successful > 0 else 0
|
||||
|
||||
logger.info("\n" + "=" * 80)
|
||||
logger.info("RÉSULTATS FINAUX")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"✓ Succès : {successful}")
|
||||
logger.info(f"✗ Échecs : {failed}")
|
||||
logger.info(f"Total traité : {len(chunks_to_process)}")
|
||||
logger.info(f"Temps total : {timedelta(seconds=int(total_time))}")
|
||||
logger.info(f"Temps moyen/chunk : {avg_time:.1f}s")
|
||||
logger.info("=" * 80)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Fonction principale."""
|
||||
logger.info("=" * 80)
|
||||
logger.info("GÉNÉRATION DE RÉSUMÉS POUR TOUS LES CHUNKS")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Vérifier Ollama
|
||||
logger.info("\n[1/3] Vérification d'Ollama...")
|
||||
try:
|
||||
ollama.list()
|
||||
logger.info(f" ✓ Ollama disponible, modèle: {OLLAMA_MODEL}")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ollama non disponible: {e}")
|
||||
logger.error(" → Vérifiez qu'Ollama est lancé (ollama serve)")
|
||||
sys.exit(1)
|
||||
|
||||
# Connexion Weaviate
|
||||
logger.info("\n[2/3] 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:
|
||||
# Traitement
|
||||
logger.info("\n[3/3] Génération des résumés...")
|
||||
logger.info("(Appuyez sur Ctrl+C pour interrompre - la progression sera sauvegardée)\n")
|
||||
process_all_chunks(client)
|
||||
|
||||
logger.info("\n✓ TERMINÉ !")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("\n⚠ Arrêt demandé par l'utilisateur")
|
||||
logger.info("→ Relancez le script pour reprendre là où vous vous êtes arrêté")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
logger.info("\n✓ Connexion Weaviate fermée")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Fix encoding pour Windows
|
||||
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
main()
|
||||
311
utils/generate_chunk_summaries.py
Normal file
311
utils/generate_chunk_summaries.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""Script de génération de résumés pour les chunks existants.
|
||||
|
||||
Ce script:
|
||||
1. Liste les œuvres disponibles dans Weaviate
|
||||
2. Permet à l'utilisateur de sélectionner une œuvre
|
||||
3. Récupère tous les chunks de cette œuvre
|
||||
4. Génère un résumé pour chaque chunk avec Ollama (qwen2.5:7b)
|
||||
5. Met à jour les chunks dans Weaviate avec les résumés générés
|
||||
|
||||
Usage:
|
||||
python utils/generate_chunk_summaries.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import ollama
|
||||
import weaviate
|
||||
from tqdm import tqdm
|
||||
|
||||
# Configuration logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("chunk_summaries.log", encoding="utf-8")
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration Ollama
|
||||
OLLAMA_MODEL = "qwen2.5:7b"
|
||||
BATCH_SIZE = 50 # Nombre de chunks à traiter par batch
|
||||
MAX_RETRIES = 3 # Nombre de tentatives en cas d'erreur
|
||||
|
||||
|
||||
def get_available_works(client: weaviate.WeaviateClient) -> List[Dict[str, Any]]:
|
||||
"""Récupère la liste des œuvres disponibles.
|
||||
|
||||
Args:
|
||||
client: Client Weaviate connecté.
|
||||
|
||||
Returns:
|
||||
Liste des œuvres avec titre, auteur et nombre de chunks.
|
||||
"""
|
||||
logger.info("Récupération des œuvres disponibles...")
|
||||
|
||||
works_collection = client.collections.get("Work")
|
||||
works = works_collection.query.fetch_objects(limit=100)
|
||||
|
||||
# Pour chaque œuvre, compter les chunks
|
||||
works_with_counts = []
|
||||
for work in works.objects:
|
||||
title = work.properties.get("title", "Sans titre")
|
||||
author = work.properties.get("author", "Auteur inconnu")
|
||||
|
||||
# Compter les chunks de cette œuvre
|
||||
chunk_collection = client.collections.get("Chunk")
|
||||
result = chunk_collection.aggregate.over_all(
|
||||
filters=weaviate.classes.query.Filter.by_property("work").by_property("title").equal(title)
|
||||
)
|
||||
chunks_count = result.total_count
|
||||
|
||||
works_with_counts.append({
|
||||
"title": title,
|
||||
"author": author,
|
||||
"chunks_count": chunks_count
|
||||
})
|
||||
|
||||
return works_with_counts
|
||||
|
||||
|
||||
def select_work(works: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Affiche les œuvres et demande à l'utilisateur de choisir.
|
||||
|
||||
Args:
|
||||
works: Liste des œuvres disponibles.
|
||||
|
||||
Returns:
|
||||
L'œuvre sélectionnée.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("ŒUVRES DISPONIBLES")
|
||||
print("=" * 80)
|
||||
|
||||
for i, work in enumerate(works, 1):
|
||||
print(f"{i}. {work['title']} - {work['author']} ({work['chunks_count']} chunks)")
|
||||
|
||||
print("=" * 80)
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = int(input(f"\nChoisissez une œuvre (1-{len(works)}): "))
|
||||
if 1 <= choice <= len(works):
|
||||
return works[choice - 1]
|
||||
else:
|
||||
print(f"Veuillez entrer un nombre entre 1 et {len(works)}")
|
||||
except ValueError:
|
||||
print("Veuillez entrer un nombre valide")
|
||||
|
||||
|
||||
def generate_summary(chunk_text: str, work_title: str, author: str) -> str:
|
||||
"""Génère un résumé dense pour un chunk avec Ollama.
|
||||
|
||||
Args:
|
||||
chunk_text: Texte du chunk à résumer.
|
||||
work_title: Titre de l'œuvre (non utilisé dans le prompt).
|
||||
author: Auteur de l'œuvre (non utilisé dans le prompt).
|
||||
|
||||
Returns:
|
||||
Résumé dense généré (100-150 mots).
|
||||
"""
|
||||
prompt = f"""TEXTE À RÉSUMER:
|
||||
{chunk_text}
|
||||
|
||||
CONSIGNES STRICTES:
|
||||
- Résumé direct de 100-150 mots maximum
|
||||
- INTERDIT: formulations méta ("Ce passage souligne...", "L'auteur affirme...", "Peirce dit que...")
|
||||
- Reformule les idées au style direct et impersonnel
|
||||
- Densité conceptuelle maximale: chaque mot compte
|
||||
- Conserve TOUS les concepts, termes techniques et noms propres
|
||||
- Structure: thèse → arguments → implications
|
||||
- Aucune perte d'information philosophique
|
||||
|
||||
RÉSUMÉ DENSE:"""
|
||||
|
||||
response = ollama.generate(
|
||||
model=OLLAMA_MODEL,
|
||||
prompt=prompt,
|
||||
options={
|
||||
"temperature": 0.2, # Très peu de créativité pour rester fidèle au texte
|
||||
"num_predict": 250, # ~150 mots maximum
|
||||
}
|
||||
)
|
||||
|
||||
return response["response"].strip()
|
||||
|
||||
|
||||
def process_work_chunks(
|
||||
client: weaviate.WeaviateClient,
|
||||
work: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Traite tous les chunks d'une œuvre pour générer des résumés.
|
||||
|
||||
Args:
|
||||
client: Client Weaviate connecté.
|
||||
work: Œuvre sélectionnée.
|
||||
"""
|
||||
title = work["title"]
|
||||
author = work["author"]
|
||||
total_chunks = work["chunks_count"]
|
||||
|
||||
logger.info(f"Traitement de '{title}' - {total_chunks} chunks")
|
||||
|
||||
# Récupérer tous les chunks de l'œuvre
|
||||
chunk_collection = client.collections.get("Chunk")
|
||||
|
||||
# Pagination pour récupérer tous les chunks
|
||||
all_chunks = []
|
||||
cursor = None
|
||||
fetch_batch_size = 500
|
||||
|
||||
logger.info("Récupération des chunks...")
|
||||
while True:
|
||||
if cursor:
|
||||
response = chunk_collection.query.fetch_objects(
|
||||
limit=fetch_batch_size,
|
||||
after=cursor,
|
||||
filters=weaviate.classes.query.Filter.by_property("work").by_property("title").equal(title)
|
||||
)
|
||||
else:
|
||||
response = chunk_collection.query.fetch_objects(
|
||||
limit=fetch_batch_size,
|
||||
filters=weaviate.classes.query.Filter.by_property("work").by_property("title").equal(title)
|
||||
)
|
||||
|
||||
if not response.objects:
|
||||
break
|
||||
|
||||
for obj in response.objects:
|
||||
all_chunks.append({
|
||||
"uuid": str(obj.uuid),
|
||||
"text": obj.properties.get("text", ""),
|
||||
"summary": obj.properties.get("summary", "")
|
||||
})
|
||||
|
||||
if len(response.objects) < fetch_batch_size:
|
||||
break
|
||||
|
||||
cursor = response.objects[-1].uuid
|
||||
|
||||
logger.info(f"✓ {len(all_chunks)} chunks récupérés")
|
||||
|
||||
# Filtrer les chunks sans résumé
|
||||
chunks_to_process = [c for c in all_chunks if not c["summary"]]
|
||||
|
||||
if not chunks_to_process:
|
||||
logger.info("✓ Tous les chunks ont déjà un résumé !")
|
||||
return
|
||||
|
||||
logger.info(f"→ {len(chunks_to_process)} chunks à traiter (résumés manquants)")
|
||||
|
||||
# Générer les résumés avec barre de progression
|
||||
print("\nGénération des résumés...")
|
||||
successful = 0
|
||||
failed = 0
|
||||
|
||||
for chunk in tqdm(chunks_to_process, desc="Chunks"):
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < MAX_RETRIES:
|
||||
try:
|
||||
# Générer le résumé
|
||||
summary = generate_summary(chunk["text"], title, author)
|
||||
|
||||
# Mettre à jour le chunk dans Weaviate
|
||||
chunk_collection.data.update(
|
||||
uuid=chunk["uuid"],
|
||||
properties={"summary": summary}
|
||||
)
|
||||
|
||||
successful += 1
|
||||
break # Succès, sortir de la boucle de retry
|
||||
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
logger.error(f"Erreur chunk {chunk['uuid']} (tentative {retry_count}/{MAX_RETRIES}): {e}")
|
||||
|
||||
if retry_count < MAX_RETRIES:
|
||||
time.sleep(2) # Pause avant retry
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(f"✗ Échec définitif pour chunk {chunk['uuid']}")
|
||||
|
||||
# Résumé final
|
||||
print("\n" + "=" * 80)
|
||||
print("RÉSULTATS")
|
||||
print("=" * 80)
|
||||
print(f"✓ Résumés générés avec succès: {successful}")
|
||||
print(f"✗ Échecs: {failed}")
|
||||
print(f"Total traité: {len(chunks_to_process)}")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Fonction principale."""
|
||||
logger.info("=" * 80)
|
||||
logger.info("GÉNÉRATION DE RÉSUMÉS POUR CHUNKS WEAVIATE")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Vérifier que Ollama est disponible
|
||||
logger.info("\n[1/4] Vérification d'Ollama...")
|
||||
try:
|
||||
ollama.list()
|
||||
logger.info(f" ✓ Ollama disponible, modèle: {OLLAMA_MODEL}")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ollama non disponible: {e}")
|
||||
logger.error(" → Vérifiez qu'Ollama est lancé (ollama serve)")
|
||||
sys.exit(1)
|
||||
|
||||
# Connexion à Weaviate
|
||||
logger.info("\n[2/4] 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:
|
||||
# Récupérer les œuvres
|
||||
logger.info("\n[3/4] Récupération des œuvres disponibles...")
|
||||
works = get_available_works(client)
|
||||
|
||||
if not works:
|
||||
logger.error(" ✗ Aucune œuvre trouvée dans la base")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f" ✓ {len(works)} œuvres disponibles")
|
||||
|
||||
# Sélection de l'œuvre
|
||||
selected_work = select_work(works)
|
||||
logger.info(f"\n→ Œuvre sélectionnée: {selected_work['title']} ({selected_work['chunks_count']} chunks)")
|
||||
|
||||
# Traitement
|
||||
logger.info("\n[4/4] Génération des résumés...")
|
||||
process_work_chunks(client, selected_work)
|
||||
|
||||
logger.info("\n✓ TERMINÉ !")
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
logger.info("\n✓ Connexion Weaviate fermée")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Fix encoding pour Windows
|
||||
if sys.platform == "win32" and hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
main()
|
||||
422
utils/llm_summarizer.py
Normal file
422
utils/llm_summarizer.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Module de génération de résumés LLM pour les sections hiérarchiques.
|
||||
|
||||
Ce module génère des résumés sémantiques enrichis avec extraction de concepts
|
||||
pour chaque section du document (niveau 1, 2, 3 de la hiérarchie).
|
||||
|
||||
Usage:
|
||||
from utils.llm_summarizer import generate_summaries_for_toc
|
||||
|
||||
summaries = generate_summaries_for_toc(
|
||||
toc=toc,
|
||||
chunks=chunks,
|
||||
provider="claude",
|
||||
model="claude-sonnet-4-5-20250929"
|
||||
)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
import anthropic
|
||||
import os
|
||||
|
||||
# Configuration logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_summary_prompt(section_title: str, section_text: str, language: str = "fr") -> str:
|
||||
"""
|
||||
Construit le prompt pour générer un résumé de section.
|
||||
|
||||
Args:
|
||||
section_title: Titre de la section (ex: "Peirce: CP 5.314 > La sémiose")
|
||||
section_text: Texte complet de la section (concaténation des chunks)
|
||||
language: Langue du résumé ("fr" ou "en")
|
||||
|
||||
Returns:
|
||||
Prompt formaté pour le LLM
|
||||
"""
|
||||
if language == "fr":
|
||||
prompt = f"""Tu es un expert en philosophie et sémiotique. Ta tâche est de résumer la section suivante d'un texte académique.
|
||||
|
||||
Titre de la section: {section_title}
|
||||
|
||||
Texte de la section:
|
||||
{section_text}
|
||||
|
||||
Tâches:
|
||||
1. Rédige un résumé en français de 150-300 mots qui capture:
|
||||
- Les idées principales et arguments centraux
|
||||
- Les concepts philosophiques clés
|
||||
- Le contexte intellectuel et les références
|
||||
- La contribution originale de l'auteur
|
||||
|
||||
2. Extrais 5-10 concepts clés (mots ou courtes expressions) qui représentent les idées centrales.
|
||||
Les concepts doivent être:
|
||||
- Des termes philosophiques importants
|
||||
- Des noms de penseurs/auteurs mentionnés
|
||||
- Des notions théoriques centrales
|
||||
- En français (même si le texte source est en anglais)
|
||||
|
||||
IMPORTANT: Réponds UNIQUEMENT avec un JSON valide au format suivant, sans markdown ni autre texte:
|
||||
|
||||
{{
|
||||
"summary": "Ton résumé détaillé en français...",
|
||||
"concepts": ["concept1", "concept2", "concept3", ...]
|
||||
}}
|
||||
"""
|
||||
else: # English
|
||||
prompt = f"""You are an expert in philosophy and semiotics. Your task is to summarize the following section from an academic text.
|
||||
|
||||
Section title: {section_title}
|
||||
|
||||
Section text:
|
||||
{section_text}
|
||||
|
||||
Tasks:
|
||||
1. Write a summary of 150-300 words in English that captures:
|
||||
- Main ideas and central arguments
|
||||
- Key philosophical concepts
|
||||
- Intellectual context and references
|
||||
- Original contribution of the author
|
||||
|
||||
2. Extract 5-10 key concepts (words or short phrases) representing central ideas.
|
||||
Concepts should be:
|
||||
- Important philosophical terms
|
||||
- Names of thinkers/authors mentioned
|
||||
- Central theoretical notions
|
||||
- In English
|
||||
|
||||
IMPORTANT: Respond ONLY with valid JSON in this format, without markdown or other text:
|
||||
|
||||
{{
|
||||
"summary": "Your detailed summary in English...",
|
||||
"concepts": ["concept1", "concept2", "concept3", ...]
|
||||
}}
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def call_claude_api(
|
||||
prompt: str,
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
max_tokens: int = 1000,
|
||||
temperature: float = 0.3,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Appelle l'API Claude pour générer un résumé.
|
||||
|
||||
Args:
|
||||
prompt: Le prompt construit
|
||||
model: Modèle Claude à utiliser
|
||||
max_tokens: Nombre maximum de tokens
|
||||
temperature: Température de génération
|
||||
|
||||
Returns:
|
||||
Dict avec:
|
||||
- summary: str - Le résumé généré
|
||||
- concepts: List[str] - Les concepts extraits
|
||||
- usage: Dict - Tokens utilisés (input_tokens, output_tokens)
|
||||
|
||||
Raises:
|
||||
ValueError: Si ANTHROPIC_API_KEY n'est pas définie
|
||||
Exception: Si l'appel API échoue
|
||||
"""
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("ANTHROPIC_API_KEY non définie dans .env")
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
logger.info(f"Appel Claude API - modèle: {model}")
|
||||
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
# Extraire le contenu texte
|
||||
content = response.content[0].text
|
||||
|
||||
# Parser le JSON
|
||||
# Nettoyer le markdown si présent
|
||||
if content.startswith("```json"):
|
||||
content = content.replace("```json", "").replace("```", "").strip()
|
||||
elif content.startswith("```"):
|
||||
content = content.replace("```", "").strip()
|
||||
|
||||
result = json.loads(content)
|
||||
|
||||
# Valider la structure
|
||||
if "summary" not in result or "concepts" not in result:
|
||||
raise ValueError(f"Réponse Claude invalide: {content}")
|
||||
|
||||
# Ajouter les statistiques d'usage
|
||||
result["usage"] = {
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Claude summary généré: {len(result['summary'])} chars, "
|
||||
f"{len(result['concepts'])} concepts, "
|
||||
f"{response.usage.input_tokens} in / {response.usage.output_tokens} out tokens"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Erreur parsing JSON Claude: {e}")
|
||||
logger.error(f"Contenu brut: {content}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur appel Claude API: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def generate_summary_for_section(
|
||||
section_title: str,
|
||||
chunks: List[Dict[str, Any]],
|
||||
provider: str = "claude",
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
language: str = "fr",
|
||||
max_text_length: int = 15000,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Génère un résumé pour une section donnée.
|
||||
|
||||
Args:
|
||||
section_title: Titre complet de la section (ex: "Peirce: CP 5.314 > La sémiose")
|
||||
chunks: Liste des chunks appartenant à cette section
|
||||
provider: Provider LLM ("claude" uniquement pour l'instant)
|
||||
model: Modèle à utiliser
|
||||
language: Langue du résumé ("fr" ou "en")
|
||||
max_text_length: Longueur maximale du texte à résumer (pour éviter les timeouts)
|
||||
|
||||
Returns:
|
||||
Dict avec:
|
||||
- summary: str - Le résumé généré
|
||||
- concepts: List[str] - Les concepts extraits
|
||||
- chunks_count: int - Nombre de chunks dans cette section
|
||||
- usage: Dict - Statistiques d'usage (tokens, etc.)
|
||||
- success: bool - Si la génération a réussi
|
||||
- error: Optional[str] - Message d'erreur si échec
|
||||
"""
|
||||
if not chunks:
|
||||
logger.warning(f"Aucun chunk pour la section '{section_title}'")
|
||||
return {
|
||||
"summary": section_title,
|
||||
"concepts": [],
|
||||
"chunks_count": 0,
|
||||
"usage": {},
|
||||
"success": False,
|
||||
"error": "No chunks found"
|
||||
}
|
||||
|
||||
# Concaténer le texte de tous les chunks de cette section
|
||||
section_text = "\n\n".join([chunk.get("text", "") for chunk in chunks])
|
||||
|
||||
# Tronquer si trop long (éviter les timeouts et coûts excessifs)
|
||||
if len(section_text) > max_text_length:
|
||||
logger.warning(
|
||||
f"Section '{section_title}' trop longue ({len(section_text)} chars), "
|
||||
f"troncature à {max_text_length} chars"
|
||||
)
|
||||
section_text = section_text[:max_text_length] + "...\n\n[Texte tronqué]"
|
||||
|
||||
# Construire le prompt
|
||||
prompt = build_summary_prompt(section_title, section_text, language)
|
||||
|
||||
# Appeler l'API
|
||||
try:
|
||||
if provider == "claude":
|
||||
result = call_claude_api(prompt, model=model)
|
||||
else:
|
||||
raise ValueError(f"Provider '{provider}' non supporté (uniquement 'claude')")
|
||||
|
||||
return {
|
||||
"summary": result["summary"],
|
||||
"concepts": result["concepts"],
|
||||
"chunks_count": len(chunks),
|
||||
"usage": result.get("usage", {}),
|
||||
"success": True,
|
||||
"error": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur génération résumé pour '{section_title}': {e}")
|
||||
return {
|
||||
"summary": section_title, # Fallback: juste le titre
|
||||
"concepts": [],
|
||||
"chunks_count": len(chunks),
|
||||
"usage": {},
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def generate_summaries_for_toc(
|
||||
toc: List[Dict[str, Any]],
|
||||
chunks: List[Dict[str, Any]],
|
||||
provider: str = "claude",
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
language: str = "fr",
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Génère des résumés pour toutes les sections de la table des matières.
|
||||
|
||||
Cette fonction:
|
||||
1. Parcourt récursivement la TOC hiérarchique
|
||||
2. Pour chaque section, trouve les chunks correspondants
|
||||
3. Génère un résumé avec concepts
|
||||
4. Retourne un dictionnaire title -> summary_data
|
||||
|
||||
Args:
|
||||
toc: Table des matières hiérarchique (liste de TOCEntry)
|
||||
chunks: Tous les chunks du document
|
||||
provider: Provider LLM ("claude")
|
||||
model: Modèle à utiliser
|
||||
language: Langue des résumés ("fr" ou "en")
|
||||
|
||||
Returns:
|
||||
Dict[section_title, summary_data] où summary_data contient:
|
||||
- summary: str
|
||||
- concepts: List[str]
|
||||
- chunks_count: int
|
||||
- level: int (niveau hiérarchique 1/2/3)
|
||||
- usage: Dict (tokens)
|
||||
- success: bool
|
||||
|
||||
Example:
|
||||
>>> summaries = generate_summaries_for_toc(toc, chunks)
|
||||
>>> summaries["Peirce: CP 5.314"]["summary"]
|
||||
"Cette section explore la théorie de la sémiose..."
|
||||
"""
|
||||
summaries = {}
|
||||
|
||||
# Créer un index chunks par sectionPath pour accès rapide
|
||||
chunks_by_section: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for chunk in chunks:
|
||||
section_path = chunk.get("sectionPath", "")
|
||||
if section_path:
|
||||
if section_path not in chunks_by_section:
|
||||
chunks_by_section[section_path] = []
|
||||
chunks_by_section[section_path].append(chunk)
|
||||
|
||||
def process_toc_entry(entry: Dict[str, Any], level: int, parent_path: str = ""):
|
||||
"""Traite récursivement une entrée TOC."""
|
||||
title = entry.get("title", "")
|
||||
if not title:
|
||||
return
|
||||
|
||||
# Construire le sectionPath (même logique que dans weaviate_ingest.py)
|
||||
if parent_path:
|
||||
section_path = f"{parent_path} > {title}"
|
||||
else:
|
||||
section_path = title
|
||||
|
||||
# Récupérer les chunks de cette section
|
||||
section_chunks = chunks_by_section.get(section_path, [])
|
||||
|
||||
if section_chunks:
|
||||
logger.info(
|
||||
f"Génération résumé pour '{section_path}' "
|
||||
f"(level {level}, {len(section_chunks)} chunks)..."
|
||||
)
|
||||
|
||||
summary_data = generate_summary_for_section(
|
||||
section_title=section_path,
|
||||
chunks=section_chunks,
|
||||
provider=provider,
|
||||
model=model,
|
||||
language=language
|
||||
)
|
||||
|
||||
# Ajouter le niveau hiérarchique
|
||||
summary_data["level"] = level
|
||||
|
||||
# Stocker dans le dictionnaire
|
||||
summaries[title] = summary_data
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
f"Aucun chunk trouvé pour '{section_path}' (level {level})"
|
||||
)
|
||||
# Créer un résumé vide
|
||||
summaries[title] = {
|
||||
"summary": title,
|
||||
"concepts": [],
|
||||
"chunks_count": 0,
|
||||
"level": level,
|
||||
"usage": {},
|
||||
"success": False,
|
||||
"error": "No chunks found"
|
||||
}
|
||||
|
||||
# Traiter récursivement les sous-sections
|
||||
children = entry.get("children", [])
|
||||
for child in children:
|
||||
process_toc_entry(child, level + 1, section_path)
|
||||
|
||||
# Traiter toutes les entrées de niveau 1
|
||||
for entry in toc:
|
||||
process_toc_entry(entry, level=1)
|
||||
|
||||
# Statistiques finales
|
||||
total_summaries = len(summaries)
|
||||
successful = sum(1 for s in summaries.values() if s["success"])
|
||||
total_input_tokens = sum(s.get("usage", {}).get("input_tokens", 0) for s in summaries.values())
|
||||
total_output_tokens = sum(s.get("usage", {}).get("output_tokens", 0) for s in summaries.values())
|
||||
|
||||
# Calculer le coût (Claude Sonnet 4.5: $3/M input, $15/M output)
|
||||
cost_input = total_input_tokens * 0.003 / 1000
|
||||
cost_output = total_output_tokens * 0.015 / 1000
|
||||
total_cost = cost_input + cost_output
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("RÉSUMÉS GÉNÉRÉS - STATISTIQUES")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Total sections: {total_summaries}")
|
||||
|
||||
if total_summaries > 0:
|
||||
logger.info(f"Succès: {successful} ({successful/total_summaries*100:.1f}%)")
|
||||
logger.info(f"Échecs: {total_summaries - successful}")
|
||||
else:
|
||||
logger.info("Succès: 0 (0.0%)")
|
||||
logger.info("Échecs: 0")
|
||||
|
||||
logger.info(f"Tokens input: {total_input_tokens:,}")
|
||||
logger.info(f"Tokens output: {total_output_tokens:,}")
|
||||
logger.info(f"Coût total: ${total_cost:.4f}")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test rapide
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Exemple de test avec un chunk fictif
|
||||
test_chunks = [
|
||||
{
|
||||
"text": "To erect a philosophical edifice that shall outlast the vicissitudes of time...",
|
||||
"sectionPath": "Peirce: CP 1.1 > PREFACE"
|
||||
}
|
||||
]
|
||||
|
||||
result = generate_summary_for_section(
|
||||
section_title="Peirce: CP 1.1 > PREFACE",
|
||||
chunks=test_chunks,
|
||||
provider="claude",
|
||||
language="fr"
|
||||
)
|
||||
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user