6035542b67
Movidos do vault Hub para centralizar scripts. Hub mantem symlinks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
5.3 KiB
Python
192 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OCR Pipeline para Documentos Contabilísticos
|
|
PDF → imagem → RapidOCR → DeepSeek → JSON estruturado
|
|
|
|
Uso: python3 ocr-invoice.py <caminho_do_pdf>
|
|
"""
|
|
import sys
|
|
import os
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
# ── CONFIG ───────────────────────────────────────────────────────
|
|
OPENCODE_GO_KEY = ""
|
|
# Ler .env automaticamente
|
|
for line in open(os.path.expanduser("~/.hermes/.env")):
|
|
line = line.strip()
|
|
if line.startswith("OPENCODE_GO_API_KEY=") and not line.startswith("#"):
|
|
OPENCODE_GO_KEY = line.split("=", 1)[1]
|
|
break
|
|
OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1"
|
|
MODEL = "deepseek-v4-flash"
|
|
OCR_DPI = 200
|
|
|
|
|
|
def pdf_to_image(pdf_path: str, dpi: int = OCR_DPI) -> str:
|
|
"""Converte primeira página do PDF para imagem PNG."""
|
|
import pymupdf
|
|
doc = pymupdf.open(pdf_path)
|
|
page = doc[0]
|
|
pix = page.get_pixmap(dpi=dpi)
|
|
img_path = f"/tmp/ocr_{Path(pdf_path).stem}.png"
|
|
pix.save(img_path)
|
|
return img_path
|
|
|
|
|
|
def ocr_image(img_path: str) -> list[dict]:
|
|
"""Executa RapidOCR na imagem. Retorna lista de {text, score, bbox}."""
|
|
from rapidocr import RapidOCR
|
|
engine = RapidOCR()
|
|
result = engine(img_path)
|
|
|
|
lines = []
|
|
if result and result.txts:
|
|
for txt, score in zip(result.txts, result.scores):
|
|
lines.append({"text": txt, "score": float(score)})
|
|
return lines
|
|
|
|
|
|
def extract_structured(ocr_lines: list[dict], pdf_name: str) -> dict:
|
|
"""Usa DeepSeek para extrair campos estruturados do texto OCR."""
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(
|
|
api_key=OPENCODE_GO_KEY,
|
|
base_url=OPENCODE_GO_URL,
|
|
)
|
|
|
|
# Texto OCR como bloco
|
|
ocr_text = "\n".join(f"[{l['score']:.2f}] {l['text']}" for l in ocr_lines)
|
|
|
|
prompt = f"""Analisa o seguinte texto extraído de um documento contabilístico (factura/recibo) via OCR.
|
|
Extrai os campos estruturados e devolve APENAS JSON válido (sem markdown, sem ```).
|
|
|
|
Texto OCR:
|
|
{ocr_text}
|
|
|
|
Nome do ficheiro: {pdf_name}
|
|
|
|
Devolve JSON com esta estrutura exata:
|
|
{{
|
|
"tipo_documento": "factura|recibo|nota_de_credito|outro",
|
|
"fornecedor": {{
|
|
"nome": "string",
|
|
"nif_cif": "string",
|
|
"morada": "string",
|
|
"telefone": "string"
|
|
}},
|
|
"cliente": {{
|
|
"nome": "string",
|
|
"nif": "string",
|
|
"morada": "string",
|
|
"telefone": "string"
|
|
}},
|
|
"documento": {{
|
|
"numero": "string",
|
|
"data": "YYYY-MM-DD",
|
|
"metodo_pagamento": "string",
|
|
"referencia": "string"
|
|
}},
|
|
"artigos": [
|
|
{{
|
|
"codigo": "string",
|
|
"descricao": "string",
|
|
"preco_unitario": 0.00,
|
|
"quantidade": 1,
|
|
"total": 0.00,
|
|
"notas": "string"
|
|
}}
|
|
],
|
|
"resumo": {{
|
|
"base_tributavel": 0.00,
|
|
"taxa_iva_percent": 0,
|
|
"iva_valor": 0.00,
|
|
"recargo_percent": 0,
|
|
"recargo_valor": 0.00,
|
|
"total_pagar": 0.00,
|
|
"moeda": "EUR"
|
|
}},
|
|
"notas": "string com observações relevantes"
|
|
}}
|
|
|
|
Regras:
|
|
- Preços com vírgula decimal (formato PT: 1.234,56 → 1234.56)
|
|
- Se campo não encontrado, usar null
|
|
- Se artigos não detectados, array vazio
|
|
- IVA: se não explícito, calcular a partir de base + total
|
|
- Moeda: EUR por defeito"""
|
|
|
|
response = client.chat.completions.create(
|
|
model=MODEL,
|
|
messages=[
|
|
{"role": "system", "content": "És um assistente especializado em extração de dados de documentos contabilísticos portugueses. Devolves sempre JSON válido."},
|
|
{"role": "user", "content": prompt}
|
|
],
|
|
temperature=0.0,
|
|
max_tokens=16384,
|
|
)
|
|
|
|
raw = response.choices[0].message.content.strip()
|
|
|
|
# Limpar possíveis wrappers markdown
|
|
if raw.startswith("```"):
|
|
raw = raw.split("\n", 1)[1]
|
|
if raw.endswith("```"):
|
|
raw = raw[:-3]
|
|
raw = raw.strip()
|
|
|
|
return json.loads(raw)
|
|
|
|
|
|
def process_invoice(pdf_path: str) -> dict:
|
|
"""Pipeline completo: PDF → JSON estruturado."""
|
|
print(f"📄 A processar: {pdf_path}")
|
|
|
|
t0 = time.time()
|
|
|
|
# 1. PDF → imagem
|
|
print(" [1/3] PDF → imagem...")
|
|
img = pdf_to_image(pdf_path)
|
|
print(f" OK ({time.time()-t0:.1f}s)")
|
|
|
|
# 2. OCR
|
|
print(" [2/3] OCR (RapidOCR)...")
|
|
t1 = time.time()
|
|
lines = ocr_image(img)
|
|
print(f" {len(lines)} linhas em {time.time()-t1:.1f}s")
|
|
|
|
# 3. Structured extraction
|
|
print(" [3/3] Extração estruturada (DeepSeek)...")
|
|
t2 = time.time()
|
|
structured = extract_structured(lines, os.path.basename(pdf_path))
|
|
print(f" OK ({time.time()-t2:.1f}s)")
|
|
|
|
elapsed = time.time() - t0
|
|
print(f"\n✅ Pipeline completo em {elapsed:.1f}s")
|
|
|
|
# Cleanup
|
|
os.remove(img)
|
|
|
|
return {
|
|
"source_file": pdf_path,
|
|
"ocr_lines": len(lines),
|
|
"processing_time_seconds": round(elapsed, 1),
|
|
"extracted_data": structured,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print(f"Uso: python3 {sys.argv[0]} <caminho_do_pdf>")
|
|
sys.exit(1)
|
|
|
|
pdf_path = sys.argv[1]
|
|
if not os.path.exists(pdf_path):
|
|
print(f"Erro: ficheiro não encontrado: {pdf_path}")
|
|
sys.exit(1)
|
|
|
|
result = process_invoice(pdf_path)
|
|
print("\n" + json.dumps(result, indent=2, ensure_ascii=False))
|