All checks were successful
Amélioration des feedbacks en cas d'erreur d'exploitation des PDF
192 lines
8.2 KiB
Python
192 lines
8.2 KiB
Python
"""Factories d'injection de dépendance — le point d'inversion de l'hexagone.
|
||
|
||
C'est ICI (et seulement ici) qu'on choisit QUEL adapter concret incarne chaque
|
||
port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
|
||
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
|
||
ports et les use cases, jamais Ollama/Mistral/etc.
|
||
"""
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import Depends, HTTPException
|
||
|
||
from app.application.adapt_campaign import AdaptCampaignUseCase
|
||
from app.application.chat import ChatUseCase
|
||
from app.application.embeddings import EmbeddingError
|
||
from app.application.generate_page import GeneratePageUseCase
|
||
from app.application.import_campaign import ImportCampaignUseCase
|
||
from app.application.import_rules import ImportRulesUseCase
|
||
from app.application.notebook_chat import NotebookChatUseCase
|
||
from app.application.notebook_deep import NotebookDeepUseCase
|
||
from app.application.notebook_rag import NotebookRagUseCase
|
||
from app.core.config import Settings, get_settings
|
||
from app.domain.ports import LLMProvider, LLMProviderError
|
||
from app.infrastructure.gemini_adapter import GeminiLLMProvider
|
||
from app.infrastructure.mistral_adapter import MistralLLMProvider
|
||
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
|
||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
|
||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
|
||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
|
||
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
|
||
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
|
||
|
||
|
||
def _effective_import_chunk_tokens(settings: Settings) -> int:
|
||
"""Taille de morceau réellement utilisable pour l'import.
|
||
|
||
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
|
||
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
|
||
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
|
||
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
|
||
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
|
||
≤ num_ctx → morceau ≤ (num_ctx − 800) / 2.7. On plafonne, avec un log pour
|
||
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
|
||
"""
|
||
requested = settings.import_chunk_tokens
|
||
if settings.llm_provider != "ollama":
|
||
return requested
|
||
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
|
||
if requested > cap:
|
||
logger.warning(
|
||
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
|
||
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
|
||
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
|
||
requested, cap, settings.llm_num_ctx,
|
||
)
|
||
return cap
|
||
return requested
|
||
|
||
|
||
def get_llm_provider(
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> LLMProvider:
|
||
"""Factory d'adapter — point d'inversion de dépendance.
|
||
|
||
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
|
||
incarne le port, en fonction du champ `llm_provider` des Settings
|
||
(modifiable a chaud depuis l'ecran Parametres de l'UI).
|
||
"""
|
||
try:
|
||
if settings.llm_provider == "onemin":
|
||
return OneMinAiLLMProvider(settings)
|
||
if settings.llm_provider == "openrouter":
|
||
return OpenRouterLLMProvider(settings)
|
||
if settings.llm_provider == "mistral":
|
||
return MistralLLMProvider(settings)
|
||
if settings.llm_provider == "gemini":
|
||
return GeminiLLMProvider(settings)
|
||
return OllamaLLMProvider(settings)
|
||
except LLMProviderError as exc:
|
||
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
|
||
# pour que le frontend puisse afficher un message actionnable.
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
|
||
def get_generate_page_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
) -> GeneratePageUseCase:
|
||
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
|
||
return GeneratePageUseCase(llm=llm)
|
||
|
||
|
||
def get_chat_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
) -> ChatUseCase:
|
||
"""Factory du use case chat.
|
||
|
||
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
|
||
et LLMChatProvider) par duck typing ; on lui passe la même instance.
|
||
"""
|
||
return ChatUseCase(llm=llm) # type: ignore[arg-type]
|
||
|
||
|
||
def get_import_rules_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> ImportRulesUseCase:
|
||
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
|
||
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
|
||
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
|
||
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
|
||
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
|
||
return ImportRulesUseCase(
|
||
llm=llm, extractor=_PDF_EXTRACTOR,
|
||
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||
segment_only=settings.llm_provider == "ollama")
|
||
|
||
|
||
def get_import_campaign_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> ImportCampaignUseCase:
|
||
"""Factory du use case d'import de campagne PDF (extraction + arborescence)."""
|
||
return ImportCampaignUseCase(
|
||
llm=llm,
|
||
extractor=_PDF_EXTRACTOR,
|
||
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||
map_concurrency=settings.llm_map_concurrency,
|
||
)
|
||
|
||
|
||
def get_adapt_campaign_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> AdaptCampaignUseCase:
|
||
"""Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés)."""
|
||
# L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing.
|
||
# Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider).
|
||
return AdaptCampaignUseCase( # type: ignore[arg-type]
|
||
llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens)
|
||
|
||
|
||
def get_embedding_provider(
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
):
|
||
"""Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`."""
|
||
try:
|
||
if settings.embedding_provider == "mistral":
|
||
return MistralEmbeddingProvider(settings)
|
||
return OllamaEmbeddingProvider(settings)
|
||
except EmbeddingError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
|
||
def get_notebook_rag_use_case(
|
||
embedder: Annotated[object, Depends(get_embedding_provider)],
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> NotebookRagUseCase:
|
||
return NotebookRagUseCase(
|
||
extractor=_PDF_EXTRACTOR,
|
||
embedder=embedder, # type: ignore[arg-type]
|
||
min_score=settings.rag_min_score,
|
||
)
|
||
|
||
|
||
def get_notebook_chat_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> NotebookChatUseCase:
|
||
return NotebookChatUseCase(
|
||
rag=rag, llm=llm, rerank_enabled=settings.rag_rerank) # type: ignore[arg-type]
|
||
|
||
|
||
def get_notebook_deep_use_case(
|
||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||
embedder: Annotated[object, Depends(get_embedding_provider)],
|
||
settings: Annotated[Settings, Depends(get_settings)],
|
||
) -> NotebookDeepUseCase:
|
||
return NotebookDeepUseCase(
|
||
llm=llm,
|
||
batch_tokens=settings.import_chunk_tokens,
|
||
map_concurrency=settings.llm_map_concurrency,
|
||
embedder=embedder,
|
||
summary_filter=settings.deep_summary_filter,
|
||
)
|