Projeto do Agent Contas ORACLE
This commit is contained in:
305
app/domain/contas/parsers/invoice_to_text.py
Normal file
305
app/domain/contas/parsers/invoice_to_text.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Transformacao da fatura normalizada (dict JSON) -> formato textual em blocos.
|
||||
|
||||
Formato economico em tokens (~72% menor que o JSON indentado), pensado para enviar
|
||||
a uma LLM. API publica: `to_text(data)` e a flag `USE_TEXT_FORMAT`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Liga o formato textual da fatura no prompt. Default conceitual: False;
|
||||
# mantido True nesta fase de rollout. Override por env, sem edicao de codigo.
|
||||
USE_TEXT_FORMAT = os.getenv("TIM_INVOICE_DETAIL_AS_TEXT", "true").lower() == "true"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# secao -> (acao, classe). O cabecalho usa o proprio nome da secao (mantido como no JSON).
|
||||
SEC_META = {
|
||||
"SVA Detalhe Total": ("cancelar", "avulso"),
|
||||
"Itens Eventuais": ("cancelar", "avulso"),
|
||||
"Serviços Bundle Inclusos": ("falar sobre", "bundle"),
|
||||
}
|
||||
|
||||
# Secoes que rendem a acao por ITEM (sem "| ação=... | classe" no header).
|
||||
# Item estrategico (estrategico=True) usa "falar sobre"; avulso, "cancelar".
|
||||
PER_ITEM_ACTION_SECTIONS = {
|
||||
"SVA Detalhe Total",
|
||||
"Itens Eventuais",
|
||||
"Mensalidades Adicionais",
|
||||
}
|
||||
|
||||
# Secoes cujo header recebe o rotulo "| cobrados a parte" (servicos avulsos
|
||||
# contratados separadamente). A acao continua por item.
|
||||
COBRADOS_A_PARTE_SECTIONS = {"SVA Detalhe Total", "Itens Eventuais"}
|
||||
|
||||
# secao do JSON -> rotulo no cabecalho do prompt. "SVA Detalhe Total" e "Itens
|
||||
# Eventuais" sao a MESMA categoria (avulsos cobrados a parte) e saem sob um unico
|
||||
# cabecalho "Itens Eventuais". "Serviços Bundle Inclusos" vira "Serviços Inclusos
|
||||
# no Plano": a palavra "bundle" e tecnica/interna e nao deve chegar ao prompt (nem
|
||||
# ao LLM). As chaves do JSON normalizado NAO mudam nos dois casos.
|
||||
SECTION_LABELS = {
|
||||
"SVA Detalhe Total": "Itens Eventuais",
|
||||
"Serviços Bundle Inclusos": "Benefícios do Plano",
|
||||
}
|
||||
|
||||
# classe interna -> rotulo exibido no cabecalho (ver SEC_META). Mesma logica do
|
||||
# SECTION_LABELS: so a APRESENTACAO muda, a classe interna ("bundle") segue igual
|
||||
# em todo o resto do backend (bill_processor, invoice_resolver, tools, etc.).
|
||||
CLASSE_LABELS = {"bundle": "incluso"}
|
||||
|
||||
_RX_OUTROS = re.compile(r"^\s*([^:()]+?)\s*:\s*\((.+)\)\s*$")
|
||||
|
||||
|
||||
def _fmt_money(value: Any) -> str:
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _snake(desc: Any) -> str:
|
||||
return re.sub(r"\s+", "_", str(desc).strip().lower())
|
||||
|
||||
|
||||
def is_period_range(period: Any) -> bool:
|
||||
"""True quando ``period`` é uma FAIXA (ciclo da fatura, ex.: "14/10 a 13/11"),
|
||||
não uma data única de cobrança. Compartilhado com o resolver, que usa o mesmo
|
||||
critério para decidir se uma entry expõe ``charge_date`` (data única) — assim o
|
||||
que o resolver enxerga como cobrança datada casa exatamente o ``data=`` do render
|
||||
lean do classificador."""
|
||||
text = str(period)
|
||||
return " a " in text or "~" in text
|
||||
|
||||
|
||||
def _item_acao(item: dict[str, Any], section: str) -> str | None:
|
||||
"""Verbo de acao por item: estrategico -> 'falar sobre'; senao, o default da secao."""
|
||||
if item.get("estrategico"):
|
||||
return "falar sobre"
|
||||
acao, _ = SEC_META.get(section, (None, None))
|
||||
return acao
|
||||
|
||||
|
||||
def _render_item(item: dict[str, Any], section: str) -> str:
|
||||
"""<nome solto> | campo=valor | ... (com tratamento especial p/ Outros Valores)."""
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
value = item.get("value")
|
||||
|
||||
if section == "Outros Valores":
|
||||
match = _RX_OUTROS.match(desc)
|
||||
if match:
|
||||
parts = [match.group(1).strip().lower()]
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
parts.append(f'ref="{match.group(2).strip()}"')
|
||||
return " | ".join(parts)
|
||||
|
||||
parts = [desc]
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
period = item.get("period")
|
||||
if period and not is_period_range(period):
|
||||
parts.append(f"data={period}")
|
||||
if item.get("franchise"):
|
||||
parts.append(f"franquia={item['franchise']}")
|
||||
if item.get("consumption"):
|
||||
parts.append(f"consumo={item['consumption']}")
|
||||
if item.get("installment"):
|
||||
parts.append(f"parcela={item['installment']}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# Seções omitidas no modo enxuto (intent classifier): o classificador só precisa
|
||||
# dos NOMES dos itens por linha/seção, não de totais, planos nem juros/multas.
|
||||
_LEAN_SKIP_SECTIONS = frozenset({"Fatura Resumo", "Planos", "Outros Valores"})
|
||||
|
||||
|
||||
def to_text(data: dict[str, Any], *, lean: bool = False) -> str:
|
||||
"""Converte o JSON normalizado de uma fatura no formato textual em blocos.
|
||||
|
||||
``lean=True`` (formato do intent classifier): mantém a estrutura LINHA/seção,
|
||||
o NOME dos itens e o ``valor``/``data`` por item — descarta "Fatura Resumo",
|
||||
"Planos", "Outros Valores" e os demais campos por-item (ação, type, franquia,
|
||||
consumo). O ``valor``/``data`` distinguem cobranças duplicadas do mesmo nome na
|
||||
mesma linha (desambiguação de cobrança). O orquestrador usa ``lean=False``
|
||||
(formato completo, com ações)."""
|
||||
lines: list[str] = []
|
||||
msisdn_count = 0
|
||||
|
||||
# FATURA (achatada) — omitida no modo enxuto.
|
||||
resumo = data.get("Fatura Resumo")
|
||||
if not lean and isinstance(resumo, list):
|
||||
lines.append("Fatura Resumo")
|
||||
for item in resumo:
|
||||
desc = item.get("desc", "?")
|
||||
if "period" in item:
|
||||
lines.append(f" {_snake(desc)}={item['period']}")
|
||||
elif "emissao" in item:
|
||||
lines.append(f" {_snake(desc)}={item['emissao']}")
|
||||
elif "value" in item:
|
||||
lines.append(f" {_snake(desc)}={_fmt_money(item['value'])}")
|
||||
lines.append("")
|
||||
|
||||
# LINHAS (MSISDN)
|
||||
for key, block in data.items():
|
||||
if key in ("Fatura Resumo", "vocalized_msisdn"):
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
msisdn_count += 1
|
||||
lines.append(f"LINHA {key}")
|
||||
|
||||
# Planos — omitido no modo enxuto.
|
||||
planos = block.get("Planos")
|
||||
if not lean and isinstance(planos, dict) and planos:
|
||||
lines.append("Planos")
|
||||
for nome, plano in planos.items():
|
||||
bits = [f"nome={nome}"]
|
||||
if plano.get("period"):
|
||||
bits.append(f"período={plano['period']}")
|
||||
if plano.get("days") is not None:
|
||||
bits.append(f"dias={plano['days']}")
|
||||
bits.append(f"valor_final={_fmt_money(plano.get('valor_final'))}")
|
||||
if plano.get("valor_bruto") not in (None, plano.get("valor_final")):
|
||||
bits.append(f"valor_bruto={_fmt_money(plano.get('valor_bruto'))}")
|
||||
if plano.get("total_descontos"):
|
||||
bits.append(f"total_desc={_fmt_money(plano.get('total_descontos'))}")
|
||||
if plano.get("is_controle"):
|
||||
bits.append("controle=sim")
|
||||
lines.append(" " + " | ".join(bits))
|
||||
descontos = plano.get("descontos") or []
|
||||
if descontos:
|
||||
lines.append(" descontos:")
|
||||
for desconto in descontos:
|
||||
dbits = [
|
||||
f"nome={desconto.get('desc')}",
|
||||
f"valor={_fmt_money(desconto.get('value'))}",
|
||||
]
|
||||
if desconto.get("installment"):
|
||||
dbits.append(f"parcela={desconto['installment']}")
|
||||
lines.append(" " + " | ".join(dbits))
|
||||
|
||||
# Seções agrupadas pelo RÓTULO do cabeçalho (``SECTION_LABELS``): as que
|
||||
# compartilham rótulo saem sob um único cabeçalho, na posição da primeira
|
||||
# delas. Cada item guarda a seção de ORIGEM, que é quem decide a ação/classe
|
||||
# por item — o rótulo é só apresentação.
|
||||
groups: dict[str, tuple[str, list[tuple[str, dict[str, Any]]]]] = {}
|
||||
for section, items in block.items():
|
||||
if section == "Planos" or not isinstance(items, list):
|
||||
continue
|
||||
if lean and section in _LEAN_SKIP_SECTIONS:
|
||||
continue
|
||||
label = SECTION_LABELS.get(section, section)
|
||||
_, entries = groups.setdefault(label, (section, []))
|
||||
entries.extend((section, item) for item in items)
|
||||
|
||||
for label, (first_section, entries) in groups.items():
|
||||
acao, classe = SEC_META.get(first_section, (None, None))
|
||||
header = label
|
||||
# No modo enxuto o cabeçalho é só o nome da seção (sem ação/classe).
|
||||
if not lean and first_section in COBRADOS_A_PARTE_SECTIONS:
|
||||
header += " | cobrados a parte"
|
||||
elif acao and first_section not in PER_ITEM_ACTION_SECTIONS and not lean:
|
||||
header += f" | ação={acao} | {CLASSE_LABELS.get(classe, classe)}"
|
||||
lines.append(header)
|
||||
for section, item in entries:
|
||||
if lean:
|
||||
# Nome do item + valor + data da cobrança (sem ação/type/
|
||||
# franquia/consumo). O valor/data por item permitem o classifier
|
||||
# DISTINGUIR N cobranças do mesmo nome na mesma linha (mesmo
|
||||
# desc/msisdn, períodos diferentes) na desambiguação de cobrança
|
||||
# duplicada — o msisdn vem do cabeçalho ``LINHA``. Itens
|
||||
# ``Incluído`` (bundle) e períodos em faixa (ciclo da fatura, não
|
||||
# data de cobrança) seguem só com o nome, como antes.
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
if not desc:
|
||||
continue
|
||||
parts = [desc]
|
||||
value = item.get("value")
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
period = item.get("period")
|
||||
if period and not is_period_range(period):
|
||||
parts.append(f"data={period}")
|
||||
lines.append(" " + " | ".join(parts))
|
||||
continue
|
||||
line = " " + _render_item(item, section)
|
||||
if section in PER_ITEM_ACTION_SECTIONS:
|
||||
item_acao = _item_acao(item, section)
|
||||
if item_acao:
|
||||
line += f" | ação={item_acao}"
|
||||
if item.get("estrategico"):
|
||||
line += " | type=estrategico"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
text = "\n".join(lines).rstrip()
|
||||
if msisdn_count > 1:
|
||||
text += "\n\nmultiplas_linhas = true"
|
||||
else:
|
||||
text += "\n\nmultiplas_linhas = false"
|
||||
return text + "\n"
|
||||
|
||||
|
||||
# ordem pedida pelo produto: avulso -> estrategico -> bundle
|
||||
# (difere da ordem informacional bundle/estrategico/avulso usada em backend.py)
|
||||
_VAS_PRODUCT_ORDER = ("avulso", "estrategico", "bundle")
|
||||
|
||||
|
||||
def get_vas_product_names(data: dict[str, Any]) -> str:
|
||||
"""Nomes dos VAS (avulso, estrategico, bundle — nessa ordem) da fatura
|
||||
normalizada, deduplicados e separados por vírgula. Ex.: "Focus Mensal,
|
||||
Tamboro Mensal". Ignora plano e demais seções não-VAS. Retorna "" se não houver."""
|
||||
buckets: dict[str, list[str]] = {c: [] for c in _VAS_PRODUCT_ORDER}
|
||||
seen: set[str] = set()
|
||||
for key, block in data.items():
|
||||
if key in ("Fatura Resumo", "vocalized_msisdn") or not isinstance(block, dict):
|
||||
continue
|
||||
for section, items in block.items():
|
||||
if section == "Planos" or not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
classe = item.get("classe")
|
||||
if not classe and item.get("estrategico"):
|
||||
classe = "estrategico"
|
||||
if classe not in buckets:
|
||||
continue
|
||||
name = str(item.get("desc", "")).strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
buckets[classe].append(name)
|
||||
ordered = [n for c in _VAS_PRODUCT_ORDER for n in buckets[c]]
|
||||
return ", ".join(ordered)
|
||||
|
||||
|
||||
def render_for_prompt(invoice_detail: Any, *, lean: bool = False) -> str | None:
|
||||
"""Renderiza ``invoice_detail`` (dict ou str JSON) no formato textual ``to_text``
|
||||
para uso no prompt. Fonte única compartilhada pelo orquestrador (system prompt)
|
||||
e pelo intent classifier — garante que ambos leiam a MESMA fatura, sem drift.
|
||||
|
||||
``lean=True`` produz o formato enxuto do intent classifier (nomes + valor/data
|
||||
por item, por linha/seção; ver :func:`to_text`); o orquestrador usa ``lean=False``.
|
||||
|
||||
Retorna ``None`` quando o formato textual está desligado (``USE_TEXT_FORMAT``
|
||||
false) ou em qualquer falha de parse/render — cabe ao chamador decidir o
|
||||
fallback (JSON cru no orquestrador; renderizador compacto no classifier).
|
||||
Nunca levanta exceção: o prompt não pode quebrar por causa da fatura."""
|
||||
if not (USE_TEXT_FORMAT and invoice_detail):
|
||||
return None
|
||||
try:
|
||||
parsed = (
|
||||
json.loads(invoice_detail)
|
||||
if isinstance(invoice_detail, str)
|
||||
else invoice_detail
|
||||
)
|
||||
if isinstance(parsed, dict) and parsed:
|
||||
return to_text(parsed, lean=lean)
|
||||
except Exception: # noqa: BLE001 — nunca quebrar o prompt
|
||||
logger.warning("invoice_to_text.render_for_prompt falhou", exc_info=True)
|
||||
return None
|
||||
Reference in New Issue
Block a user