amélioration import ollama
All checks were successful
All checks were successful
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
Reference in New Issue
Block a user