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