Améliorations sur l'utilisation de l'IA pour l'exploitation des PDF, que ce soit la partie cloud ou la partie ollama + montée en version
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m43s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m46s

This commit is contained in:
2026-06-11 01:31:24 +02:00
parent cff2ceb0b9
commit a1f3b9b796
21 changed files with 287 additions and 48 deletions

View File

@@ -25,7 +25,12 @@ from app.application.streaming import with_heartbeat
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__)
@@ -102,6 +107,29 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
def _coerce_markdown(value: object) -> str:
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
Malgré la consigne « valeurs = markdown », certains modèles nichent des
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
place pour ne perdre aucun contenu.
"""
if isinstance(value, str):
return value
if isinstance(value, dict):
parts = []
for k, v in value.items():
content = _coerce_markdown(v)
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
# elle-même porte le contenu (dérive observée sur certains modèles).
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
return "\n\n".join(parts)
if isinstance(value, list):
return "\n\n".join(_coerce_markdown(v) for v in value)
return "" if value is None else str(value)
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
@@ -241,8 +269,24 @@ class ImportRulesUseCase:
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections."
)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
try:
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Le modèle générait mais trop lentement pour réécrire tout le morceau
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b)
sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
@@ -276,4 +320,4 @@ class ImportRulesUseCase:
if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False
return {str(k): str(v) for k, v in parsed.items()}, recovered
return {str(k): _coerce_markdown(v) for k, v in parsed.items()}, recovered

View File

@@ -38,13 +38,16 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
obj = extract_json_object(raw)
if obj is not None:
try:
return json.loads(obj), False
# strict=False : tolère les caractères de contrôle BRUTS (retours à la
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
# mode JSON natif, qui invalidait toute la réponse.
return json.loads(obj, strict=False), False
except json.JSONDecodeError:
pass
repaired = repair_truncated_json(raw)
if repaired is not None:
try:
return json.loads(repaired), True
return json.loads(repaired, strict=False), True
except json.JSONDecodeError:
pass
return None, False

View File

@@ -14,7 +14,7 @@ import asyncio
import logging
import re
from app.domain.ports import LLMProvider, LLMProviderError
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
logger = logging.getLogger(__name__)
@@ -74,6 +74,12 @@ async def generate_with_retry(
for attempt in range(_ATTEMPTS):
try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMGenerationTimeout:
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
# sait re-découper le morceau en deux pour réduire la sortie.
raise
except LLMProviderError as exc:
last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite