A tua fatura é lida por uma IA?

Não. Explicamos aqui exatamente o que acontece ao PDF que carregas, passo a passo, e onde é que existe (e não existe) inteligência artificial neste sistema.

Como é que a fatura é lida, então?

Por regras fixas de leitura de texto — o programa procura padrões conhecidos (ex: "0,1671 €/kWh", "6,9 kVA") no texto do PDF, o mesmo tipo de técnica usada há décadas para ler faturas e recibos automaticamente. Não há nenhum "chatbot" nem modelo de IA a interpretar ou a "perceber" o conteúdo da tua fatura.

Algumas faturas (de certos comercializadores, com "programa certificado") não têm texto nenhum dentro do PDF — o documento desenha os números como imagem, não como texto selecionável. Nesses casos, e só nesses casos, usamos reconhecimento ótico de caracteres (OCR) — a mesma tecnologia de qualquer scanner de documentos ou app de digitalização do telemóvel — para converter a imagem em texto antes de aplicar as mesmas regras fixas de sempre.

🔍 Ver o código real que lê a tua fatura

Exatamente como está no servidor agora — lido do próprio ficheiro, não uma cópia à parte. Sem chamadas a nenhuma IA em lado nenhum.

"""Extração de dados de faturas de eletricidade — qualquer comercializador.

Não há fórmula/segredo nenhum de comercializador aqui: isto só lê os números
que já estão impressos na fatura (consumo, potência, preço atual). O layout
está calibrado e bem testado para a G9 (fatura real usada como referência),
mas os padrões são suficientemente genéricos para apanhar faturas de outros
comercializadores com rótulos parecidos. Frágil por natureza (regex sobre
texto extraído de PDF) — se os campos-chave não forem encontrados,
`missing_fields` fica preenchido e o chamador (main.py) mostra um formulário
manual de recurso em vez de um beco sem saída.
"""

from __future__ import annotations

import io
import logging
import re
from dataclasses import dataclass, field

import pdfplumber
import pytesseract

logger = logging.getLogger("electricity-advisor")

# Faturas geradas por alguns programas certificados (ex: EDP, "Contém
# Assinatura Digital") vêm sem nenhuma camada de texto — o texto é desenhado
# como vetor/curvas para fidelidade de impressão, não como carateres
# selecionáveis. pdfplumber devolve 0 carateres nesse caso; sem OCR, faturas
# assim iam sempre parar ao formulário manual, mesmo com uma fatura perfeita
# de um comercializador enorme. Limite de páginas para não deixar um PDF com
# centenas de páginas prender o processo em OCR (cada página demora ~1-2s).
_OCR_MIN_TEXT_LEN = 20
_OCR_MAX_PAGES = 4
_OCR_RESOLUTION = 200

# Gamas plausíveis para um perfil residencial em Portugal — última rede de
# segurança antes de aceitar um valor extraído (por regex OU por OCR) como
# verdadeiro. OCR pode ler "0,1671" como "1,671" (vírgula deslocada) ou outros
# erros de dígito; para uma ferramenta que informa uma decisão financeira,
# mais vale cair no formulário manual do que confiar num número implausível.
_PLAUSIBLE_RANGES: dict[str, tuple[float, float]] = {
    "monthly_kwh": (1.0, 5000.0),
    "contracted_power_kva": (1.0, 42.0),
    "current_energy_price_eur_kwh": (0.02, 0.60),
    "current_power_price_eur_dia": (0.02, 3.0),
}

REQUIRED_FIELDS = (
    "monthly_kwh",
    "contracted_power_kva",
    "opcao_horaria",
    "current_energy_price_eur_kwh",
    "current_power_price_eur_dia",
)

# Só para a frase-resumo ("o teu comercializador é a X") — não bloqueia nada
# se não encontrar; a comparação funciona na mesma sem saber o nome.
KNOWN_RETAILERS = [
    "G9", "EDP", "Endesa", "Galp", "Iberdrola", "Repsol", "Goldenergy",
    "MEO Energia", "SU Eletricidade", "Coopérnico", "Plenitude", "YEM",
    "Alfa Energia", "Fortia", "Audax",
]

# Palavras que sinalizam tarifário indexado ao mercado grossista — se
# nenhuma aparecer perto da tarifa, assume-se fixo (é o caso mais comum e é
# o que a maioria das faturas antigas/tradicionais tem).
INDEXED_KEYWORDS = ["indexad", "dinâmic", "dinamic", "OMIE", "mercado grossista", "mercado spot"]


@dataclass
class ParsedInvoice:
    monthly_kwh: float | None = None
    contracted_power_kva: float | None = None
    opcao_horaria: str | None = None
    current_energy_price_eur_kwh: float | None = None  # média ponderada quando `periods` está preenchido
    current_power_price_eur_dia: float | None = None
    # Consumo/preço por período ("vazio"/"ponta"/"cheia"/"fora_vazio") — só
    # preenchido quando os períodos têm preços DIFERENTES entre si (bi/tri-
    # horário a sério). Quando todos os períodos custam o mesmo, isso é
    # equivalente a Simples e fica None (nada a ganhar em separar).
    periods: dict[str, tuple[float, float]] | None = None
    retailer: str | None = None
    tariff_type: str = "fixo"  # "fixo" | "indexado" — heurística, ver _detect_tariff_type
    missing_fields: list[str] = field(default_factory=list)

    @property
    def is_usable(self) -> bool:
        return not any(f in self.missing_fields for f in REQUIRED_FIELDS)


def _pt_number(raw: str) -> float:
    """Converte um número em formato pt-PT ('1.234,56' ou '0,1348') para float."""
    return float(raw.replace(".", "").replace(",", "."))


def extract_text(file_bytes: bytes) -> str:
    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        text = "\n".join(page.extract_text() or "" for page in pdf.pages)
    if len(text.strip()) >= _OCR_MIN_TEXT_LEN:
        return text
    logger.warning("PDF sem camada de texto extraível, a tentar OCR")
    return _extract_text_ocr(file_bytes)


def _extract_text_ocr(file_bytes: bytes) -> str:
    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        parts = [
            pytesseract.image_to_string(page.to_image(resolution=_OCR_RESOLUTION).original, lang="por")
            for page in pdf.pages[:_OCR_MAX_PAGES]
        ]
    return "\n".join(parts)


def _detect_retailer(text: str) -> str | None:
    for name in KNOWN_RETAILERS:
        if re.search(re.escape(name), text, re.IGNORECASE):
            return name
    return None


def _detect_tariff_type(text: str) -> str:
    for kw in INDEXED_KEYWORDS:
        if re.search(kw, text, re.IGNORECASE):
            return "indexado"
    return "fixo"


# Formato tabular alternativo, visto em faturas emitidas por outro motor de
# faturação (ex: Goldenergy — "Microsoft Dynamics NAV-PT Localization"): sem
# as frases narrativas da G9 ("Potência Contratada (X kVA): Y €/dia"), em vez
# disso uma tabela com colunas Início/Fim/Qtd./Unid./Preço(€)/Valor(€)/IVA(%).
# Descoberto ao testar com uma fatura real da Goldenergy que os padrões da G9
# não apanhavam nada disto.

_POWER_ROW_RE = re.compile(
    r"Pot[êe]ncia Contratada\s+([\d.,]+)\s*kVA\s+\d{2}/\d{2}/\d{4}\s+\d{2}/\d{2}/\d{4}\s+\d+\s*Dia\s+([\d.,]+)",
    re.IGNORECASE,
)
# Nomes de período: Vazio/Ponta/Cheia (tri-horário) e Fora de Vazio/Fora Vazio
# (bi-horário), além de Simples — cobre os dois ciclos horários oficiais em
# Portugal, não só o tri-horário da fatura Goldenergy usada como referência.
_ENERGY_ROW_RE = re.compile(
    r"Consumo Eletricidade (Vazio|Ponta|Cheia|Fora\s*(?:de\s*)?Vazio|Simples) medido\s+"
    r"\d{2}/\d{2}/\d{4}\s+\d{2}/\d{2}/\d{4}\s+([\d.,]+)\s*kWh\s+([\d.,]+)",
    re.IGNORECASE,
)


def _normalize_period_name(raw: str) -> str:
    """"Vazio"/"vazio" -> "vazio"; "Fora Vazio"/"Fora de Vazio" -> "fora_vazio"."""
    normalized = re.sub(r"\s+", " ", raw.strip().lower())
    if "fora" in normalized:
        return "fora_vazio"
    return normalized


def _extract_power_tabular(text: str) -> tuple[float | None, float | None]:
    """(kVA, €/dia total) — pode haver duas linhas (acesso às redes +
    comercializador) para o mesmo escalão de potência; somam-se, tal como a
    G9 já soma isto numa única frase."""
    matches = _POWER_ROW_RE.findall(text)
    if not matches:
        return None, None
    kva = _pt_number(matches[0][0])
    total_price = sum(_pt_number(price) for _, price in matches)
    return kva, total_price


# Formato EDP (via OCR — ver extract_text): sem frases narrativas nem a
# tabela Início/Fim/Qtd./Unid. da Goldenergy. Em vez disso, uma linha com a
# opção horária ("Simples") seguida da linha "<intervalo de datas> N kWh
# PREÇO €", e uma linha "Potência (N kVA)" seguida de "<datas> N dias PREÇO
# €". Os nomes dos meses variam consoante o período faturado, por isso os
# padrões não os fixam — só a distância (poucos carateres) até ao número
# seguinte, para não apanhar sítios errados do documento (ex: "Simples"
# reaparece na frase-resumo da potência contratada e no parágrafo descritivo
# do consumo, sem "kWh" próximo, por isso essas ocorrências falham e o regex
# avança para a linha certa).
_EDP_ENERGY_RE = re.compile(r"\bSimples\b[\s\S]{0,60}?([\d.,]+)\s*k[Ww][Hh]\s+([\d.,]+)\s*€")
_EDP_POWER_RE = re.compile(r"Pot[êe]ncia\s*\(\s*([\d.,]+)\s*k[Vv][Aa]\)[\s\S]{0,60}?\d+\s*dias?\s+([\d.,]+)\s*€")


def _extract_energy_edp(text: str) -> tuple[float | None, float | None]:
    """(kWh, €/kWh) do formato EDP — só tarifário Simples (o único suportado
    nesta versão; Bi/Tri-horário teriam um rótulo diferente de "Simples")."""
    m = _EDP_ENERGY_RE.search(text)
    if not m:
        return None, None
    return _pt_number(m.group(1)), _pt_number(m.group(2))


def _extract_power_edp(text: str) -> tuple[float | None, float | None]:
    """(kVA, €/dia) do formato EDP."""
    m = _EDP_POWER_RE.search(text)
    if not m:
        return None, None
    return _pt_number(m.group(1)), _pt_number(m.group(2))


def _extract_energy_tabular(
    text: str,
) -> tuple[str | None, float | None, float | None, dict[str, tuple[float, float]] | None]:
    """(opcao_horaria, kWh total, €/kWh médio ponderado, detalhe por período)
    a partir de linhas "Consumo Eletricidade <Período> medido ... N kWh
    PREÇO". Um mesmo período pode aparecer em mais do que uma linha (faturas
    reais dividem por escalão de IVA — os primeiros 200 kWh/mês a 6%, o
    resto a 23% — sempre ao mesmo preço base); por isso soma-se o consumo
    por período antes de decidir se os preços diferem entre períodos.
    Períodos todos ao MESMO preço = efetivamente Simples (não há nada a
    ganhar em distinguir); preços diferentes = bi/tri-horário a sério, com
    `periods` preenchido para o cálculo exato do custo atual."""
    matches = _ENERGY_ROW_RE.findall(text)
    if not matches:
        return None, None, None, None

    by_period: dict[str, list[tuple[float, float]]] = {}
    for period, kwh_str, price_str in matches:
        by_period.setdefault(_normalize_period_name(period), []).append(
            (_pt_number(kwh_str), _pt_number(price_str))
        )

    periods: dict[str, tuple[float, float]] = {}
    for period_key, rows in by_period.items():
        period_kwh = sum(kwh for kwh, _ in rows)
        period_price = sum(kwh * price for kwh, price in rows) / period_kwh if period_kwh > 0 else rows[0][1]
        periods[period_key] = (period_kwh, period_price)

    total_kwh = sum(kwh for kwh, _ in periods.values())
    distinct_prices = {round(price, 6) for _, price in periods.values()}
    weighted_avg = sum(kwh * price for kwh, price in periods.values()) / total_kwh if total_kwh > 0 else None

    if len(distinct_prices) <= 1:
        # um único período (ex: "simples"), ou vários com o mesmo preço —
        # equivalente a Simples, sem necessidade de detalhe por período.
        return "simples", total_kwh, weighted_avg, None

    opcao = "bi-horario" if len(periods) == 2 else "tri-horario"
    return opcao, total_kwh, weighted_avg, periods


def parse_invoice(file_bytes: bytes) -> ParsedInvoice:
    text = extract_text(file_bytes)
    result = ParsedInvoice()
    result.retailer = _detect_retailer(text)
    result.tariff_type = _detect_tariff_type(text)

    # ── Potência ──
    # 1ª tentativa: "Potência Contratada (6,90 kVA): 0,4498 €/dia." (G9;
    # frase narrativa, rótulo comum a outros comercializadores).
    m = re.search(
        r"Pot[êe]ncia\s*Contratada\s*\(?\s*([\d.,]+)\s*kVA\)?\s*:?\s*([\d.,]+)?\s*€/dia",
        text,
        re.IGNORECASE,
    )
    if m and m.group(2):
        result.contracted_power_kva = _pt_number(m.group(1))
        result.current_power_price_eur_dia = _pt_number(m.group(2))
    else:
        # 2ª tentativa: formato tabular (ex: Goldenergy) — ver
        # _extract_power_tabular. Descoberto numa fatura real que não tinha
        # nenhuma frase narrativa, só linhas de tabela.
        kva, power_price = _extract_power_tabular(text)
        if kva is None or power_price is None:
            # 3ª tentativa: formato EDP (ver _extract_power_edp) — só chega
            # aqui via OCR, já que a fatura EDP real testada não tinha
            # camada de texto nenhuma.
            edp_kva, edp_power_price = _extract_power_edp(text)
            kva = edp_kva if kva is None else kva
            power_price = edp_power_price if power_price is None else power_price
        if kva is not None and power_price is not None:
            result.contracted_power_kva = kva
            result.current_power_price_eur_dia = power_price
        else:
            if kva is not None:
                result.contracted_power_kva = kva
            elif m and m.group(1):
                result.contracted_power_kva = _pt_number(m.group(1))
            else:
                result.missing_fields.append("contracted_power_kva")
            result.missing_fields.append("current_power_price_eur_dia")

    # ── Energia + opção horária + consumo ──
    # 1ª tentativa: "Energia Ativa (Simples): 0,1348 €/kWh." (G9; frase
    # narrativa — outros comercializadores costumam usar "Preço de
    # Energia"/"Tarifa" com o mesmo formato).
    m = re.search(
        r"(?:Energia\s*Ativa|Pre[çc]o\s*(?:de\s*)?Energia|Tarifa)\s*"
        r"\((Simples|Bi-?hor[aá]rio|Tri-?hor[aá]rio)\)\s*:?\s*([\d.,]+)\s*€/kWh",
        text,
        re.IGNORECASE,
    )
    if m:
        result.opcao_horaria = m.group(1).lower().replace("á", "a")
        result.current_energy_price_eur_kwh = _pt_number(m.group(2))
    else:
        # 2ª tentativa: formato tabular (linhas "Consumo Eletricidade
        # <Período> medido ... N kWh PREÇO") — dá logo opção horária,
        # consumo total, preço médio E detalhe por período, tudo de uma vez.
        opcao, total_kwh, price, periods = _extract_energy_tabular(text)
        if opcao is not None and price is not None:
            result.opcao_horaria = opcao
            result.current_energy_price_eur_kwh = price
            result.monthly_kwh = total_kwh
            result.periods = periods
        else:
            # 3ª tentativa: formato EDP (ver _extract_energy_edp).
            edp_kwh, edp_price = _extract_energy_edp(text)
            if edp_kwh is not None and edp_price is not None:
                result.opcao_horaria = "simples"
                result.current_energy_price_eur_kwh = edp_price
                result.monthly_kwh = edp_kwh
            else:
                result.missing_fields.append("opcao_horaria")
                result.missing_fields.append("current_energy_price_eur_kwh")

    # Se o consumo ainda não veio da extração tabular acima, tenta os
    # padrões narrativos da G9. Padrões específicos em alternativa, não um
    # único regex genérico à volta da palavra solta "consumo" — essa palavra
    # aparece várias vezes na fatura em contextos que nada têm a ver com o
    # total mensal (ex: nomes de linhas de imposto como "Imposto especial
    # consumo eletricidade"), e um regex demasiado permissivo apanhava o
    # "kWh" errado mais abaixo no documento.
    if result.monthly_kwh is None:
        consumo_patterns = [
            r"consumo\s+entre\b.*?foi\s*de\s*([\d.,]+)\s*kWh",  # "...consumo entre X e Y foi de N kWh" (G9)
            r"consumo\s+total\D{0,20}([\d.,]+)\s*kWh",  # "consumo total: N kWh"
            r"consumo\s*\(kWh\)\D{0,10}([\d.,]+)",  # "Consumo (kWh): N"
        ]
        m = None
        for pattern in consumo_patterns:
            m = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
            if m:
                break
        if m:
            result.monthly_kwh = _pt_number(m.group(1))
        else:
            result.missing_fields.append("monthly_kwh")

    if result.opcao_horaria and result.opcao_horaria != "simples" and not result.periods:
        # Sabemos que não é Simples (ex: narrativa "Energia Ativa
        # (Bi-horário)") mas não conseguimos separar o consumo por período —
        # sem isso não dá para calcular o custo corretamente, e nunca
        # inventamos uma distribuição. Nunca visto ainda numa fatura real
        # (só o formato tabular, que já vem com o detalhe), mas mantido como
        # rede de segurança.
        result.missing_fields.append("opcao_horaria_unsupported")

    # Última rede de segurança: um valor fora de gama plausível (ex: OCR leu
    # "0,1671" como "1,671") vira "em falta" em vez de alimentar um veredito
    # financeiro errado com confiança total — ver _PLAUSIBLE_RANGES. Aplica-se
    # também a cada período individualmente, não só à média.
    for field_name, (lo, hi) in _PLAUSIBLE_RANGES.items():
        value = getattr(result, field_name)
        if value is not None and not (lo <= value <= hi):
            logger.warning("%s fora de gama plausível (%s), a tratar como em falta", field_name, value)
            setattr(result, field_name, None)
            result.periods = None
            if field_name not in result.missing_fields:
                result.missing_fields.append(field_name)

    if result.periods:
        lo, hi = _PLAUSIBLE_RANGES["current_energy_price_eur_kwh"]
        if any(not (lo <= price <= hi) for _, price in result.periods.values()):
            logger.warning("preço de um período fora de gama plausível, a descartar o detalhe por período")
            result.periods = None

    return result
"""Cliente mínimo do protocolo INSTREAM do clamd.

Evita depender do pyclamd (não mantido) — o protocolo é simples o suficiente
para implementar diretamente: enviar os bytes em chunks prefixados pelo
tamanho, terminar com um chunk vazio, ler a resposta. Qualquer falha de
ligação levanta ScanUnavailable — o chamador (main.py) trata isso como
"recusar o upload", nunca como "está limpo", porque um clamd em baixo não
pode ser motivo para saltar a verificação.
"""

from __future__ import annotations

import os
import socket
import struct

CLAMD_HOST = os.environ.get("CLAMD_HOST", "clamav")
CLAMD_PORT = int(os.environ.get("CLAMD_PORT", "3310"))
CHUNK_SIZE = 8192
TIMEOUT_SECONDS = 15


class ScanUnavailable(Exception):
    pass


def scan_bytes(data: bytes) -> tuple[bool, str | None]:
    """Devolve (limpo, nome_da_ameaça_ou_None). Levanta ScanUnavailable se o
    clamd não responder ou responder de forma inesperada."""
    try:
        with socket.create_connection((CLAMD_HOST, CLAMD_PORT), timeout=TIMEOUT_SECONDS) as sock:
            sock.sendall(b"zINSTREAM\0")
            for offset in range(0, len(data), CHUNK_SIZE):
                chunk = data[offset : offset + CHUNK_SIZE]
                sock.sendall(struct.pack("!L", len(chunk)) + chunk)
            sock.sendall(struct.pack("!L", 0))

            response = b""
            while True:
                part = sock.recv(4096)
                if not part:
                    break
                response += part
    except OSError as exc:
        raise ScanUnavailable(str(exc)) from exc

    text = response.decode("utf-8", errors="replace").strip("\0").strip()
    if text.endswith("OK"):
        return True, None
    if "FOUND" in text:
        threat = text.rsplit(":", 1)[-1].replace("FOUND", "").strip()
        return False, threat
    raise ScanUnavailable(f"resposta inesperada do clamd: {text!r}")

O que acontece ao PDF depois?

É processado inteiramente em memória e apagado assim que a resposta é gerada — nunca é guardado em disco, em nenhuma base de dados, nem em lado nenhum. Não existem contas nem login: cada visita é completamente independente das outras, não sabemos quem carregou o quê.

Antes de sequer tentarmos ler o conteúdo, o ficheiro passa por um antivírus — para nossa proteção e da tua, já que o site aceita PDFs de qualquer pessoa na internet.

Então onde é que há IA neste sistema?

Só numa coisa, e é sempre sobre informação pública, nunca sobre a tua fatura: uma vez por dia, um assistente de IA pesquisa a internet (sites dos comercializadores, comparadores de preços) para manter atualizadas as tabelas de comparação de mercado que vês neste site. Este processo corre de forma completamente separada do momento em que carregas uma fatura, e não tem qualquer forma de aceder ao que carregas — as faturas nunca chegam a ser guardadas nalgum lado que esse processo pudesse ler, mesmo que quisesse.

E a segurança do site em geral?

Além do antivírus em cada ficheiro recebido, há limites de tamanho e de número de pedidos por minuto (para evitar abusos), e o programa que processa os PDFs corre isolado, sem privilégios especiais e sem acesso à restante rede do servidor onde este site está alojado.

Se ainda assim preferires não carregar a fatura nenhuma, podes usar o formulário manual e introduzir os valores diretamente — os mesmos números que já estão impressos na tua fatura, sem carregar nenhum PDF.