Files
linear-coding-agent/utils/llm_summarizer.py
David Blanc Brioir 636ad6206c 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>
2026-01-07 22:56:03 +01:00

423 lines
14 KiB
Python

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