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