Files

154 lines
5.7 KiB
Python

"""Extração de documentos fiscais via OpenAI Vision, com fallback local.
`extract_with_ai` recebe as imagens de um arquivo enviado (páginas de PDF já
renderizadas em PNG, ou a própria imagem) e devolve uma lista de `RawExtraction`
— ou `None` para sinalizar que o chamador deve cair no OCR/heurística local.
A superfície da SDK usada (chat.completions + response_format json_object +
entrada de imagem por data URI) foi verificada contra `openai` 2.x.
"""
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from .config import get_settings
logger = logging.getLogger("lernotafiscal.ai")
@dataclass
class RawExtraction:
"""Resultado bruto de UM documento fiscal, antes de normalizar a data."""
supplier_name: str | None
purchase_date_raw: str | None
total_paid: float | None
legible: bool
uncertain_fields: list[str] = field(default_factory=list)
extractor: str = "openai"
raw_text: str = ""
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
_SYSTEM_PROMPT = (
"Você é um extrator de dados de notas e cupons fiscais brasileiros. "
"Responda SEMPRE em JSON válido, sem texto fora do JSON."
)
_USER_PROMPT = """As imagens a seguir são documentos fiscais (notas, cupons, recibos). Podem conter mais de um documento.
Para CADA documento distinto, extraia:
- "fornecedor": razão social ou nome fantasia do emissor (o mais destacado no cabeçalho), ou null.
- "data_compra": data da compra no formato "YYYY-MM-DD", ou null se ilegível/ausente.
- "valor_pago": número (ponto decimal) do TOTAL efetivamente pago. Use o "VALOR TOTAL"/"TOTAL A PAGAR"/"VALOR PAGO". NUNCA use "TROCO" nem "DINHEIRO RECEBIDO". Se parcelado, use o total da compra. Null se ilegível.
- "legivel": true se você leu os campos com confiança; false se o documento está borrado, cortado ou ilegível.
- "campos_incertos": lista dos campos que ficaram duvidosos (ex.: ["data_compra","valor_pago"]).
Responda exatamente neste formato:
{"documentos": [{"fornecedor": ..., "data_compra": ..., "valor_pago": ..., "legivel": ..., "campos_incertos": [...]}]}
Se nenhum documento fiscal for identificável, responda {"documentos": []}."""
def _data_uri(path: Path) -> str | None:
mime = _MIME.get(path.suffix.lower())
if not mime:
return None
try:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
except OSError:
return None
return f"data:{mime};base64,{encoded}"
def _coerce_float(value: object) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return round(float(value), 2)
text = str(value).strip().replace("R$", "").replace(" ", "")
if not text:
return None
# aceita "1.234,56" e "1234.56"
if "," in text and "." in text:
text = text.replace(".", "").replace(",", ".")
elif "," in text:
text = text.replace(",", ".")
try:
return round(float(text), 2)
except ValueError:
return None
def _parse_response(content: str) -> list[RawExtraction]:
data = json.loads(content)
docs = data.get("documentos") if isinstance(data, dict) else None
if not isinstance(docs, list):
return []
results: list[RawExtraction] = []
for item in docs:
if not isinstance(item, dict):
continue
supplier = item.get("fornecedor")
supplier = str(supplier).strip()[:200] if supplier else None
date_raw = item.get("data_compra")
date_raw = str(date_raw).strip() if date_raw else None
total = _coerce_float(item.get("valor_pago"))
legible = bool(item.get("legivel", True))
uncertain = item.get("campos_incertos") or []
uncertain = [str(x) for x in uncertain if x] if isinstance(uncertain, list) else []
# segurança: campos faltando são inerentemente incertos
for name, val in (("fornecedor", supplier), ("data_compra", date_raw), ("valor_pago", total)):
if val is None and name not in uncertain:
uncertain.append(name)
if uncertain and legible and len(uncertain) >= 2:
legible = False
results.append(
RawExtraction(
supplier_name=supplier,
purchase_date_raw=date_raw,
total_paid=total,
legible=legible,
uncertain_fields=uncertain,
)
)
return results
def extract_with_ai(image_paths: list[Path]) -> list[RawExtraction] | None:
"""Extrai via OpenAI. Retorna None se IA indisponível ou em erro (=> fallback)."""
settings = get_settings()
if not settings.openai_enabled:
return None
uris = [uri for p in image_paths[: settings.openai_max_pages] if (uri := _data_uri(p))]
if not uris:
return None
try:
from openai import OpenAI
client = OpenAI(api_key=settings.openai_api_key)
content: list[dict] = [{"type": "text", "text": _USER_PROMPT}]
for uri in uris:
content.append({"type": "image_url", "image_url": {"url": uri}})
response = client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": content},
],
response_format={"type": "json_object"},
temperature=0,
)
message = response.choices[0].message.content or "{}"
return _parse_response(message)
except Exception as exc: # rede, cota, parsing, modelo indisponível...
logger.warning("Extração OpenAI falhou, usando fallback local: %s", exc)
return None