Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
All checks were successful
All checks were successful
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
@@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
@@ -22,6 +23,8 @@ from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://api.1min.ai/api/chat-with-ai"
|
||||
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
|
||||
|
||||
@@ -48,6 +51,18 @@ class OneMinAiLLMProvider:
|
||||
"promptObject": {"prompt": prompt},
|
||||
}
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
"""Message d'erreur lisible. Un timeout httpx a un str() vide → on le nomme."""
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur 1min.ai : délai dépassé (timeout {self._timeout}s). Le modèle a mis "
|
||||
"trop de temps à répondre — typique d'un morceau d'import trop gros. "
|
||||
"Réduisez « Taille des morceaux à l'import » (Paramètres → Import de PDF) "
|
||||
"ou augmentez le timeout LLM."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur 1min.ai ({exc.__class__.__name__}) : {detail}"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -55,18 +70,18 @@ class OneMinAiLLMProvider:
|
||||
output_format: str | None = None, # 1min.ai ne supporte pas format=json
|
||||
temperature: float | None = None, # idem, pas d'hyperparam expose ici
|
||||
) -> str:
|
||||
"""Appel one-shot : retourne la reponse complete sous forme de string."""
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
_API_BASE, headers=self._headers(), json=self._payload(prompt)
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc
|
||||
"""One-shot, mais via l'endpoint STREAMING (puis recollage).
|
||||
|
||||
return self._extract_result(data)
|
||||
On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues
|
||||
generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la
|
||||
connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets
|
||||
circulent en continu => pas de 524, quelle que soit la duree. On accumule
|
||||
tous les fragments pour reconstituer la reponse complete.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
async for token in self._stream_prompt(prompt):
|
||||
chunks.append(token)
|
||||
return "".join(chunks)
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
@@ -75,17 +90,18 @@ class OneMinAiLLMProvider:
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame via SSE.
|
||||
|
||||
1min.ai expose deux evenements utiles :
|
||||
- `event: content` → `data: {"content": "..."}`
|
||||
- `event: done` → fin du stream
|
||||
- `event: error` → erreur serveur
|
||||
On yield le champ `content` au fil de l'arrivee.
|
||||
"""
|
||||
"""Streame une conversation : aplatit les messages puis delegue au coeur SSE."""
|
||||
prompt = self._flatten_messages(messages, system_prompt)
|
||||
url = f"{_API_BASE}?isStreaming=true"
|
||||
async for token in self._stream_prompt(prompt):
|
||||
yield token
|
||||
|
||||
async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]:
|
||||
"""Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut.
|
||||
|
||||
1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`,
|
||||
`event: error`. On yield le champ `content` au fil de l'arrivee.
|
||||
"""
|
||||
url = f"{_API_BASE}?isStreaming=true"
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
@@ -95,9 +111,7 @@ class OneMinAiLLMProvider:
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors du streaming 1min.ai : {exc}"
|
||||
) from exc
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
# --- Helpers ------------------------------------------------------------
|
||||
|
||||
@@ -146,12 +160,21 @@ class OneMinAiLLMProvider:
|
||||
"""
|
||||
record = payload.get("aiRecord") or {}
|
||||
detail = record.get("aiRecordDetail") or {}
|
||||
result = detail.get("resultObject") or []
|
||||
if isinstance(result, list):
|
||||
result = detail.get("resultObject")
|
||||
if isinstance(result, list) and result:
|
||||
return "".join(str(x) for x in result)
|
||||
if isinstance(result, str):
|
||||
if isinstance(result, str) and result:
|
||||
return result
|
||||
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
|
||||
|
||||
# Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer.
|
||||
# Causes frequentes : credits/quota 1min.ai epuises, moderation, modele
|
||||
# indisponible, ou reponse asynchrone (record cree mais resultat pas encore
|
||||
# pret). Sans ce detail, l'erreur "resultObject absent" est aveugle.
|
||||
snippet = json.dumps(payload, ensure_ascii=False)
|
||||
if len(snippet) > 800:
|
||||
snippet = snippet[:800] + "…"
|
||||
logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet)
|
||||
raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}")
|
||||
|
||||
@staticmethod
|
||||
def _flatten_messages(
|
||||
|
||||
102
brain/app/infrastructure/pdf_extractor.py
Normal file
102
brain/app/infrastructure/pdf_extractor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor.
|
||||
|
||||
Stratégie HYBRIDE auto :
|
||||
1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF
|
||||
"born-digital" (livres de règles officiels type Nimble, faits dans
|
||||
InDesign/Affinity : très graphiques mais avec une vraie couche texte)
|
||||
passent par là → rapide, fidèle, AUCUN OCR.
|
||||
2. Si une page ne rend (quasi) aucun texte → c'est probablement une image
|
||||
(scan ou page 100% illustrée). On rasterise la page et on la passe à
|
||||
Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes.
|
||||
|
||||
Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il
|
||||
est absent (ex: exécution locale Windows sans install), l'OCR est désactivé
|
||||
proprement : les pages-images ressortent vides mais l'extraction ne plante pas,
|
||||
et le diagnostic le signale (used_ocr reste False, texte vide).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
|
||||
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
|
||||
# qui échoue sur `from frontend import *`.
|
||||
|
||||
from app.domain.models import ExtractedDocument, ExtractedPage
|
||||
from app.domain.ports import PdfExtractionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# En dessous de ce nombre de caractères "significatifs" sur une page, on
|
||||
# considère qu'il n'y a pas de couche texte exploitable → repli OCR.
|
||||
_MIN_TEXT_CHARS = 20
|
||||
|
||||
# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du
|
||||
# texte de livre. Plus haut = plus lent et plus gourmand en mémoire.
|
||||
_OCR_DPI = 300
|
||||
|
||||
# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont
|
||||
# des termes anglais résiduels). Doivent être installées dans l'image Docker
|
||||
# (tesseract-ocr-fra, tesseract-ocr-eng).
|
||||
_OCR_LANGS = "fra+eng"
|
||||
|
||||
|
||||
class PyMuPdfTextExtractor:
|
||||
"""Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# On détecte la disponibilité de l'OCR une seule fois (le binaire
|
||||
# Tesseract ne va pas apparaître/disparaître en cours d'exécution).
|
||||
self._ocr_available = self._detect_ocr()
|
||||
|
||||
@staticmethod
|
||||
def _detect_ocr() -> bool:
|
||||
"""True si pytesseract + le binaire Tesseract sont disponibles."""
|
||||
try:
|
||||
import pytesseract
|
||||
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception as exc: # ImportError, TesseractNotFoundError, etc.
|
||||
logger.warning(
|
||||
"OCR indisponible (Tesseract non installé ?) : %s. "
|
||||
"Les pages sans couche texte ressortiront vides.",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
|
||||
try:
|
||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
except Exception as exc:
|
||||
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
|
||||
|
||||
pages: list[ExtractedPage] = []
|
||||
try:
|
||||
for index, page in enumerate(doc):
|
||||
text = (page.get_text() or "").strip()
|
||||
used_ocr = False
|
||||
if len(text) < _MIN_TEXT_CHARS and self._ocr_available:
|
||||
ocr_text = self._ocr_page(page)
|
||||
if ocr_text.strip():
|
||||
text = ocr_text.strip()
|
||||
used_ocr = True
|
||||
pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr))
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
return ExtractedDocument(pages=pages)
|
||||
|
||||
@staticmethod
|
||||
def _ocr_page(page: "fitz.Page") -> str:
|
||||
"""Rasterise une page et lui applique l'OCR Tesseract."""
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
pix = page.get_pixmap(dpi=_OCR_DPI)
|
||||
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
try:
|
||||
return pytesseract.image_to_string(img, lang=_OCR_LANGS)
|
||||
except Exception as exc:
|
||||
logger.warning("Échec OCR sur la page %s : %s", page.number, exc)
|
||||
return ""
|
||||
Reference in New Issue
Block a user