Parallelisation des appels MAP (import campagne + analyse approfondie)

Les morceaux/lots sont traites par vagues de llm_map_concurrency appels
simultanes (defaut 3, .env). L ordre narratif est preserve (fusion vague par
vague dans l ordre du livre), la resilience par morceau et les heartbeats SSE
sont conserves. Divise le temps d import d un gros livre par ~3 sur un
provider cloud ; sans effet sur Ollama local (qui sequence cote serveur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:18:51 +02:00
parent e7aa67bc42
commit 8369886f42
4 changed files with 109 additions and 53 deletions

View File

@@ -92,7 +92,11 @@ def get_import_campaign_use_case(
) -> ImportCampaignUseCase: ) -> ImportCampaignUseCase:
"""Factory du use case d'import de campagne PDF (extraction + arborescence).""" """Factory du use case d'import de campagne PDF (extraction + arborescence)."""
return ImportCampaignUseCase( return ImportCampaignUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) llm=llm,
extractor=_PDF_EXTRACTOR,
chunk_target_tokens=settings.import_chunk_tokens,
map_concurrency=settings.llm_map_concurrency,
)
def get_adapt_campaign_use_case( def get_adapt_campaign_use_case(
@@ -140,4 +144,8 @@ def get_notebook_deep_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookDeepUseCase: ) -> NotebookDeepUseCase:
return NotebookDeepUseCase(llm=llm, batch_tokens=settings.import_chunk_tokens) return NotebookDeepUseCase(
llm=llm,
batch_tokens=settings.import_chunk_tokens,
map_concurrency=settings.llm_map_concurrency,
)

View File

@@ -10,6 +10,7 @@ PROPOSITION non persistée : le Core crée les entités seulement après revue.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from app.application.chunking import chunk_text, split_in_half from app.application.chunking import chunk_text, split_in_half
@@ -217,10 +218,15 @@ class ImportCampaignUseCase:
llm: LLMProvider, llm: LLMProvider,
extractor: PdfTextExtractor, extractor: PdfTextExtractor,
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS, chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
map_concurrency: int = 1,
) -> None: ) -> None:
self._llm = llm self._llm = llm
self._extractor = extractor self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens self._chunk_target_tokens = chunk_target_tokens
# Appels MAP par VAGUES de cette taille : l'ordre narratif est préservé
# (fusion vague par vague, dans l'ordre du livre) mais le mur d'attente
# des appels LLM est divisé d'autant. 1 = comportement séquentiel.
self._map_concurrency = max(1, map_concurrency)
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult: async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
"""Variante non-streamée : traite tout puis renvoie l'arbre complet.""" """Variante non-streamée : traite tout puis renvoie l'arbre complet."""
@@ -228,9 +234,15 @@ class ImportCampaignUseCase:
chunks = chunk_text(doc.full_text, self._chunk_target_tokens) chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc) toc_block = _format_toc(doc.toc)
merger = _TreeMerger() merger = _TreeMerger()
for i, chunk in enumerate(chunks): total = len(chunks)
merger.add(await self._map_chunk( for start in range(0, total, self._map_concurrency):
chunk, index=i, total=len(chunks), toc_block=toc_block)) wave = list(enumerate(chunks))[start:start + self._map_concurrency]
results = await asyncio.gather(*(
self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave
))
for res in results:
merger.add(res)
return CampaignImportResult( return CampaignImportResult(
arcs=merger.result(), arcs=merger.result(),
page_count=doc.page_count, page_count=doc.page_count,
@@ -266,38 +278,50 @@ class ImportCampaignUseCase:
merger = _TreeMerger() merger = _TreeMerger()
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
for i, chunk in enumerate(chunks): done_count = 0
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est # PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que # appels simultanés. L'ordre narratif est préservé : la fusion se fait
# si AUCUN morceau ne passe (cf. après la boucle). # vague par vague, dans l'ordre du livre.
# HEARTBEAT : keep-alive pendant l'appel LLM pour ne jamais laisser le # RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# flux SSE silencieux (sinon le Core coupe sur timeout d'inactivité). # SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
try: # si AUCUN morceau ne passe (cf. après la boucle).
arcs_payload: list[dict] | None = None # HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
async for kind, payload in with_heartbeat( # laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
self._map_chunk(chunk, index=i, total=total, toc_block=toc_block) for start in range(0, total, self._map_concurrency):
): wave = list(enumerate(chunks))[start:start + self._map_concurrency]
if kind == "heartbeat": gathered = asyncio.gather(
yield {"type": "heartbeat", "current": i + 1, "total": total} *(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
else: for i, c in wave),
arcs_payload = payload return_exceptions=True,
merger.add(arcs_payload or []) )
except LLMProviderError as exc: results: list | None = None
skipped += 1 async for kind, payload in with_heartbeat(gathered):
last_error = str(exc) if kind == "heartbeat":
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc) yield {"type": "heartbeat", "current": done_count + 1, "total": total}
yield {"type": "chunk_failed", "current": i + 1, "total": total, else:
"message": str(exc)[:300]} results = payload
arcs, chapters, scenes = merger.counts() for (i, _), res in zip(wave, results or []):
yield { done_count += 1
"type": "progress", if isinstance(res, LLMProviderError):
"current": i + 1, skipped += 1
"total": total, last_error = str(res)
"arc_count": arcs, logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
"chapter_count": chapters, yield {"type": "chunk_failed", "current": i + 1, "total": total,
"scene_count": scenes, "message": str(res)[:300]}
"skipped": skipped, elif isinstance(res, BaseException):
} raise res # bug inattendu : ne pas l'avaler en silence
else:
merger.add(res or [])
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": done_count,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"skipped": skipped,
}
if total > 0 and skipped == total: if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite. # Tout a échoué : "done" vide serait trompeur → erreur explicite.

View File

@@ -13,6 +13,7 @@ lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import AsyncIterator from typing import AsyncIterator
@@ -62,9 +63,13 @@ Réponds en français."""
class NotebookDeepUseCase: class NotebookDeepUseCase:
def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None: def __init__(
self, llm: LLMProvider, batch_tokens: int = 10000, map_concurrency: int = 1
) -> None:
self._llm = llm self._llm = llm
self._batch_tokens = max(2000, batch_tokens) self._batch_tokens = max(2000, batch_tokens)
# Lots MAP traités par vagues de cette taille (parallélisme LLM).
self._map_concurrency = max(1, map_concurrency)
async def stream( async def stream(
self, self,
@@ -92,21 +97,21 @@ class NotebookDeepUseCase:
batches = self._group(chunks) batches = self._group(chunks)
total = len(batches) total = len(batches)
notes: list[str] = [] notes: list[str] = []
for i, batch in enumerate(batches): # Lots traités par VAGUES parallèles ; les notes restent dans l'ordre du
yield {"type": "progress", "current": i, "total": total} # document (gather préserve l'ordre des tâches de la vague).
excerpt = "\n\n".join( for start in range(0, total, self._map_concurrency):
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip() yield {"type": "progress", "current": start, "total": total}
for c in batch wave = batches[start:start + self._map_concurrency]
) results = await asyncio.gather(
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) *(self._map_batch(question, b) for b in wave), return_exceptions=True)
try: for j, res in enumerate(results):
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE) if isinstance(res, LLMProviderError):
except LLMProviderError as exc: logger.warning(
logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc) "Analyse approfondie : lot %s/%s ignoré : %s", start + j + 1, total, res)
continue elif isinstance(res, BaseException):
answer = raw.strip() raise res # bug inattendu : ne pas l'avaler
if answer and answer.upper().rstrip(".") != _NO_MATCH: elif res:
notes.append(answer) notes.append(res)
yield {"type": "progress", "current": total, "total": total} yield {"type": "progress", "current": total, "total": total}
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)" notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
@@ -136,6 +141,19 @@ class NotebookDeepUseCase:
)} )}
yield {"type": "done"} yield {"type": "done"}
async def _map_batch(self, question: str, batch: list[dict]) -> str:
"""Phase MAP d'un lot : extrait les infos pertinentes ('' si RAS)."""
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
return answer
return ""
def _group(self, chunks: list[dict]) -> list[list[dict]]: def _group(self, chunks: list[dict]) -> list[list[dict]]:
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken).""" """Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
enc = tiktoken.get_encoding("cl100k_base") enc = tiktoken.get_encoding("cl100k_base")

View File

@@ -90,6 +90,12 @@ class Settings(BaseSettings):
# les hors-sujet 0.2-0.4. Montable à ~0.4 si trop de bruit, 0 = désactivé. # les hors-sujet 0.2-0.4. Montable à ~0.4 si trop de bruit, 0 = désactivé.
rag_min_score: float = 0.30 rag_min_score: float = 0.30
# Nombre d'appels LLM MAP menes EN PARALLELE (import de campagne, analyse
# approfondie). 3 = bon defaut cloud (divise le temps d'un gros livre par ~3).
# Ollama local sequence les requetes de toute facon (pas de gain, pas de mal).
# Baisser a 1 si un provider gratuit rate-limite agressivement.
llm_map_concurrency: int = 3
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.