Analyse approfondie : index de resumes pour ne relire que les lots pertinents

A la premiere analyse d une source, chaque lot est resume (1 appel LLM,
cache disque, purge avec la source) et son resume embedde. Aux questions
suivantes, la question est comparee aux resumes et seuls les lots proches
du meilleur score (marge 0.10, plancher 3 lots) sont relus -> 3-5x moins
d appels sur un gros livre pour les questions ciblees. Selection
volontairement conservatrice ; best-effort (tout echec -> plein scan) ;
desactivable via DEEP_SUMMARY_FILTER=false (exhaustivite maximale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:51:09 +02:00
parent 3d1cf6e495
commit e26d11a99f
4 changed files with 167 additions and 8 deletions

View File

@@ -80,6 +80,42 @@ def exists(source_id: str) -> bool:
def delete(source_id: str) -> None:
_CACHE.pop(source_id, None)
_path(source_id).unlink(missing_ok=True)
_summaries_path(source_id).unlink(missing_ok=True)
# --- Index de résumés (analyse approfondie) ----------------------------------
# Cache disque des résumés PAR LOT d'une source : construit paresseusement à la
# première analyse approfondie, réutilisé ensuite pour ne relire que les lots
# pertinents. Invalidé avec la source (delete) et si batch_tokens change.
def _summaries_path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.summaries.json"
def save_summaries(source_id: str, batch_tokens: int, entries: list[dict]) -> None:
"""Persiste les résumés de lots ({"summary": str, "vector": [...]})."""
_STORE_DIR.mkdir(parents=True, exist_ok=True)
payload = {"batch_tokens": int(batch_tokens), "entries": entries}
_summaries_path(source_id).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def load_summaries(source_id: str, batch_tokens: int) -> list[dict] | None:
"""Résumés de lots d'une source, ou None si absents / construits avec une
autre taille de lot (le découpage ne correspondrait plus)."""
p = _summaries_path(source_id)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or data.get("batch_tokens") != int(batch_tokens):
return None
entries = data.get("entries")
return entries if isinstance(entries, list) else None
def _load(source_id: str) -> list[dict]:
@@ -139,6 +175,11 @@ def _chunk_words(chunk: dict) -> frozenset[str]:
return words
# Alias public du cosinus (réutilisé par l'index de résumés de l'analyse
# approfondie — même métrique que la recherche).
cosine_similarity = _cosine
def search(
source_ids: list[str],
query_vector: list[float],