diff --git a/brain/app/api/deps.py b/brain/app/api/deps.py index a60ae73..a93fa87 100644 --- a/brain/app/api/deps.py +++ b/brain/app/api/deps.py @@ -5,6 +5,7 @@ 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 @@ -29,11 +30,39 @@ 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: @@ -83,7 +112,8 @@ def get_import_rules_use_case( ) -> ImportRulesUseCase: """Factory du use case d'import de règles PDF (extraction + structuration).""" return ImportRulesUseCase( - llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) + llm=llm, extractor=_PDF_EXTRACTOR, + chunk_target_tokens=_effective_import_chunk_tokens(settings)) def get_import_campaign_use_case( @@ -94,7 +124,7 @@ def get_import_campaign_use_case( return ImportCampaignUseCase( llm=llm, extractor=_PDF_EXTRACTOR, - chunk_target_tokens=settings.import_chunk_tokens, + chunk_target_tokens=_effective_import_chunk_tokens(settings), map_concurrency=settings.llm_map_concurrency, ) diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index e2631ff..d0c72c3 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -243,9 +243,21 @@ class ImportRulesUseCase: f"Dernier message : {last_error or 'inconnu'}"} return + sections = merger.result() + if total > 0 and not sections: + # Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON + # exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit + # un `done` vide et l'utilisateur conclut à tort que le PDF est illisible. + yield {"type": "error", + "message": "Le texte du PDF a été extrait, mais le modèle n'a produit " + "aucune section exploitable (réponses JSON vides ou coupées). " + "Réduisez la taille des morceaux d'import, augmentez la fenêtre " + "de contexte (num_ctx) ou essayez un autre modèle."} + return + yield { "type": "done", - "sections": merger.result(), + "sections": sections, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count, "skipped": skipped, diff --git a/brain/app/application/llm_json.py b/brain/app/application/llm_json.py index 81b4bc9..19c5614 100644 --- a/brain/app/application/llm_json.py +++ b/brain/app/application/llm_json.py @@ -57,12 +57,20 @@ def looks_like_truncated_json(raw: str) -> bool: """La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés) plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute - sous-structure complète). On exige un contenu substantiel pour éviter les - faux positifs sur une courte réponse non-JSON.""" - s = (raw or "").strip() - if "{" not in s or len(s) < 100: + sous-structure complète). + + Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades, + même très courte : en mode JSON un `{"` de 2 caractères est une génération + interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le + signal de re-découpage. Pour le reste (prose contenant des accolades), on exige + un contenu substantiel pour éviter les faux positifs.""" + s = _strip_reasoning(raw or "").strip() + if "{" not in s: return False - return s.count("{") > s.count("}") or s.count("[") > s.count("]") + unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]") + if s.startswith("{"): + return unbalanced + return len(s) >= 100 and unbalanced def extract_json_object(raw: str) -> str | None: diff --git a/brain/app/infrastructure/ollama_adapter.py b/brain/app/infrastructure/ollama_adapter.py index e5af2e2..0cd72a0 100644 --- a/brain/app/infrastructure/ollama_adapter.py +++ b/brain/app/infrastructure/ollama_adapter.py @@ -5,6 +5,7 @@ Isole le reste de l'application des spécificités du protocole Ollama demain, on écrit un nouvel adapter sans toucher au reste du code. """ import json +import logging from typing import AsyncIterator import httpx @@ -13,6 +14,8 @@ from app.core.config import Settings from app.domain.models import ChatMessage from app.domain.ports import LLMGenerationTimeout, LLMProviderError +logger = logging.getLogger(__name__) + class OllamaLLMProvider: """Implémentation des ports LLM — appelle un serveur Ollama via HTTP. @@ -92,7 +95,24 @@ class OllamaLLMProvider: f"Erreur lors de l'appel à Ollama : {exc}" ) from exc - return response.json()["response"] + data = response.json() + # Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que + # la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…) + # et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé + # en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille + # du prompt en tokens du modèle (les morceaux sont mesurés en tokens + # cl100k, ~20-40% plus compacts que les tokenizers locaux). + done_reason = data.get("done_reason") + if done_reason and done_reason != "stop": + logger.warning( + "Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, " + "sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la " + "fenêtre de contexte est pleine : réduisez la taille des morceaux " + "d'import ou augmentez num_ctx (Paramètres).", + done_reason, data.get("prompt_eval_count"), + data.get("eval_count"), self._num_ctx, + ) + return data["response"] async def stream_chat( self, diff --git a/brain/app/main.py b/brain/app/main.py index 81a9283..ac70b29 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.12.1-beta", + version="0.12.2-beta", ) logger = logging.getLogger(__name__) diff --git a/core/pom.xml b/core/pom.xml index 397d360..1977439 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.12.1-beta + 0.12.2-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/web/package-lock.json b/web/package-lock.json index 176ef31..6e59ad3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.12.1-beta", + "version": "0.12.2-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.12.1-beta", + "version": "0.12.2-beta", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", diff --git a/web/package.json b/web/package.json index 595d413..34b5738 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.12.1-beta", + "version": "0.12.2-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng",