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:
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user