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:
2026-01-07 22:56:03 +01:00
parent feb215dae0
commit 636ad6206c
40 changed files with 11937 additions and 712 deletions

1
utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utils package for Library RAG."""

View 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()

View 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
View 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))