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
|
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
|
||||||
ports et les use cases, jamais Ollama/Mistral/etc.
|
ports et les use cases, jamais Ollama/Mistral/etc.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
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.openrouter_adapter import OpenRouterLLMProvider
|
||||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
|
# 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.
|
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
|
||||||
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
|
_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(
|
def get_llm_provider(
|
||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
) -> LLMProvider:
|
) -> LLMProvider:
|
||||||
@@ -83,7 +112,8 @@ def get_import_rules_use_case(
|
|||||||
) -> ImportRulesUseCase:
|
) -> ImportRulesUseCase:
|
||||||
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
|
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
|
||||||
return ImportRulesUseCase(
|
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(
|
def get_import_campaign_use_case(
|
||||||
@@ -94,7 +124,7 @@ def get_import_campaign_use_case(
|
|||||||
return ImportCampaignUseCase(
|
return ImportCampaignUseCase(
|
||||||
llm=llm,
|
llm=llm,
|
||||||
extractor=_PDF_EXTRACTOR,
|
extractor=_PDF_EXTRACTOR,
|
||||||
chunk_target_tokens=settings.import_chunk_tokens,
|
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||||||
map_concurrency=settings.llm_map_concurrency,
|
map_concurrency=settings.llm_map_concurrency,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -243,9 +243,21 @@ class ImportRulesUseCase:
|
|||||||
f"Dernier message : {last_error or 'inconnu'}"}
|
f"Dernier message : {last_error or 'inconnu'}"}
|
||||||
return
|
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 {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"sections": merger.result(),
|
"sections": sections,
|
||||||
"page_count": doc.page_count,
|
"page_count": doc.page_count,
|
||||||
"ocr_page_count": doc.ocr_page_count,
|
"ocr_page_count": doc.ocr_page_count,
|
||||||
"skipped": skipped,
|
"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)
|
"""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
|
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
|
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
|
sous-structure complète).
|
||||||
faux positifs sur une courte réponse non-JSON."""
|
|
||||||
s = (raw or "").strip()
|
Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
|
||||||
if "{" not in s or len(s) < 100:
|
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 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:
|
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.
|
demain, on écrit un nouvel adapter sans toucher au reste du code.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,6 +14,8 @@ from app.core.config import Settings
|
|||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OllamaLLMProvider:
|
class OllamaLLMProvider:
|
||||||
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
|
"""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}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
) from 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(
|
async def stream_chat(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.12.1-beta</version>
|
<version>0.12.2-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.1-beta",
|
"version": "0.12.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.1-beta",
|
"version": "0.12.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.16",
|
"@angular/animations": "^21.2.16",
|
||||||
"@angular/common": "^21.2.16",
|
"@angular/common": "^21.2.16",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.1-beta",
|
"version": "0.12.2-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
Reference in New Issue
Block a user