fix: product_role never infers OWN_PRODUCTION from a single COMPOSICAO occurrence

Missão 4 / Bloco A.1 (prioridade máxima): central.app.field_contract.infer_role
treated ANY C000032.TIPO=COMPOSICAO row for a product as proof of own
production, with no ratio check - a beer sold 19 times and used once as a
recipe component (Agent 2, real data) was classified OWN_PRODUCTION from
that single occurrence, proposing to change its CFOP and IPPT.

New deterministic threshold in field_contract.py: COMPOSICAO must be a
majority (>=70%) of that product's own C000032 movement history, with at
least 5 total events, to count as own-production evidence. Below 30% the
evidence is discarded entirely (falls through to other role evidence,
e.g. TIPO=VENDA -> RESALE). Between 30% and 70% (with enough events to be
meaningful) is genuinely conflicting evidence -> UNKNOWN, never guessed
either way. RECEITA_INGREDIENTES/PROD_COZINHA-based evidence (a direct
recipe-table linkage, not a statistical ratio) is untouched.

agent/src/gti_fiscal_agent/firebird_reader.py now counts COMPOSICAO vs other
C000032 rows per product (read_product_evidence: single indexed query;
read_evidence: one grouped query for the whole catalog) into a new
ev["composed_ratio"] key, alongside the existing "composed" set (kept for
other, non-role consumers like coverage.py). central/app/enrichment/api.py's
sanitize_product_evidence and fiscal_completion.py's empty-ev fallback both
carry the new key through.

New tests (central/tests/test_field_contract.py) cover the 5 real cases named
in the mission: the beer (1 vs 19, 5% - discarded), a second beer (3 vs 4,
42.9% - MIXED/UNKNOWN), the liqueur (34 vs 22, 60.7% - still MIXED/UNKNOWN,
below the 70% bar), the juice with PROD_COZINHA=1+RECEITA_INGREDIENTES
(unaffected, stays OWN_PRODUCTION), and the tilapia/corvina fish (no
composed evidence at all, stays RESALE) - plus a strong 90% majority case
and an insufficient-sample (<5 events) case. A new end-to-end test in
agent/tests/test_cfop_ippt_auto_apply.py (committed separately, see the
feature/cfop-ippt-write branch) proves the weak-evidence beer never reaches
select_candidates as a write candidate, independent of any write flag.

Re-running the dry-run over Agent 2's real database (GTI.FDB, 527 products)
after this fix: AUTO_FILL_SAFE CFOP/IPPT cells drop from 12 to 3 (the two
fish CFOP fills + the juice's IPPT, all with strong, non-statistical
evidence) - docs/runs/GTI_dryrun_universe_summary_after_A1.json. This
corrects the Missão 3 report's 12/9(downgraded)/4(sustained) manual
judgment call with a deterministic, tested engine rule.

No schema/migration change. Zero Firebird writes (read-only analysis only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-28 10:53:10 -03:00
co-authored by Claude Sonnet 5
parent a3ced77fc5
commit 1abb7e516e
8 changed files with 379 additions and 13 deletions
+5 -1
View File
@@ -26,10 +26,14 @@ def sanitize_product_evidence(raw) -> dict | None:
validation (never trusts an authenticated Agent to send something huge or malformed for a single-product payload)."""
if not isinstance(raw, dict):
return None
out = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
out = {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
for key in ("recipes", "composed"):
if isinstance(raw.get(key), list):
out[key] = {str(v) for v in raw[key][:8]}
if isinstance(raw.get("composed_ratio"), dict):
for code, counts in list(raw["composed_ratio"].items())[:8]:
if isinstance(counts, dict) and isinstance(counts.get("composed"), int) and isinstance(counts.get("other"), int):
out["composed_ratio"][str(code)] = {"composed": max(0, counts["composed"]), "other": max(0, counts["other"])}
if isinstance(raw.get("purchases"), dict):
for code, docs in list(raw["purchases"].items())[:8]:
if isinstance(docs, list):
+1 -1
View File
@@ -110,7 +110,7 @@ def resolve_fiscal_fields(session, *, product_code: str, description: str, curre
refs = build_refs(session, uf=company.get("uf"), cfop_candidates=cfop_candidates)
# Agent-provided, product_code-targeted evidence (recipes/composed/purchases/stock - see firebird_reader.read_product_evidence): a real,
# indexed per-product query, never a full-table scan or a guess. Falls back to "no evidence" only when the Agent hasn't sent it yet.
ev = ev if isinstance(ev, dict) else {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
ev = ev if isinstance(ev, dict) else {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
d = decision or {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ba_st_mva_status": None, "classification": {"cest_candidates": [], "ipi_candidates": [], "ba_st_mva": {}}}
raw = fc.resolve_product(p, d, company, refs, ev, ctx)
out = {}
+33 -1
View File
@@ -113,6 +113,33 @@ def _r(field, state, current, proposed=None, reason="", key=None, scope=None, **
"write_eligible": state == CHANGE and CONTRACT[field]["WRITE_ALLOWED"], **extra}
# C000032.TIPO=COMPOSICAO evidence must be a deterministic MAJORITY of that product's own movement
# history to count as own-production proof, never a bare "it happened at least once" check (a beer
# used once as a recipe component among 19 direct sales is not "own production"). Thresholds:
# below MINORITY -> composed evidence is discarded entirely (too rare to mean anything); above
# DOMINANT -> strong own-production evidence; in between (with enough events to be meaningful) ->
# genuinely conflicting evidence, never guessed either way (Missão 4 / Bloco A.1).
COMPOSED_MIN_TOTAL_EVENTS = 5
COMPOSED_MINORITY_RATIO = 0.30
COMPOSED_DOMINANT_RATIO = 0.70
def _composed_signal(ev, code):
"""None (no usable evidence), "DOMINANT", "MINORITY" or "MIXED", plus the ratio for the reason string."""
info = (ev.get("composed_ratio") or {}).get(code)
if not info:
return (None, None)
total = info.get("composed", 0) + info.get("other", 0)
if total < COMPOSED_MIN_TOTAL_EVENTS:
return (None, None)
ratio = info["composed"] / total
if ratio >= COMPOSED_DOMINANT_RATIO:
return ("DOMINANT", ratio)
if ratio <= COMPOSED_MINORITY_RATIO:
return ("MINORITY", ratio)
return ("MIXED", ratio)
def infer_role(p, ev, ctx, code, group, ncm=None):
v, scope = ctx.get("product_role", code, group, ncm)
if v in ("RESALE", "OWN_PRODUCTION", "INPUT", "SERVICE"):
@@ -121,7 +148,12 @@ def infer_role(p, ev, ctx, code, group, ncm=None):
return "SERVICE", ["C000025.PRODUTO_SERVICO"]
if (_s(p.get("TIPO")) or "").upper() == "MATERIA-PRIMA":
return "INPUT", ["C000025.TIPO=MATERIA-PRIMA"]
own = [n for n, on in (("PROD_COZINHA=1", p.get("PROD_COZINHA") == 1), ("RECEITA_INGREDIENTES", code in ev.get("recipes", ())), ("C000032.TIPO=COMPOSICAO", code in ev.get("composed", ()))) if on]
composed_signal, composed_ratio = _composed_signal(ev, code)
if composed_signal == "MIXED":
return "UNKNOWN", [f"C000032.TIPO=COMPOSICAO_MIXED_EVIDENCE(ratio={composed_ratio:.1%})"]
composed_dominant = composed_signal == "DOMINANT"
own = [n for n, on in (("PROD_COZINHA=1", p.get("PROD_COZINHA") == 1), ("RECEITA_INGREDIENTES", code in ev.get("recipes", ())),
("C000032.TIPO=COMPOSICAO", composed_dominant)) if on]
docs = bool(ev.get("purchases", {}).get(code)) or ev.get("stock_entry", {}).get(code, False)
stock = ev.get("stock_in", {}).get(code, False)
if own and docs:
+41
View File
@@ -221,6 +221,47 @@ def test_role_inference_is_evidence_based_and_conflicts_stay_unknown():
assert infer_role(product(PROD_COZINHA=1), {**EV, "recipes": {"000001"}}, Context([]), "000001", "1")[0] == "OWN_PRODUCTION"
def test_composed_evidence_needs_a_dominant_majority_never_a_single_occurrence():
"""Missão 4 / Bloco A.1: C000032.TIPO=COMPOSICAO must be a deterministic MAJORITY of that
product's own movement history to prove OWN_PRODUCTION - real cases audited from Agent 2 (2026-09-28)."""
def ev_with(composed, other):
return {**EV, "composed_ratio": {"000001": {"composed": composed, "other": other}}}
# A cerveja: 1 composição vs 19 vendas (5%) - minoria clara, evidência descartada -> cai para o
# papel padrão (aqui sem TIPO=VENDA/docs no fixture, então UNKNOWN por falta de outra evidência;
# o ponto do teste é que NUNCA vira OWN_PRODUCTION).
assert infer_role(product(), ev_with(1, 19), Context([]), "000001", "1")[0] != "OWN_PRODUCTION"
r = infer_role(product(TIPO="VENDA"), {**ev_with(1, 19), "stock_in": {"000001": True}}, Context([]), "000001", "1")
assert r[0] == "RESALE" # a cerveja, com evidência de venda, é revenda - nunca produção própria
# O refrigerante/2ª cerveja: 3 composições vs 4 vendas (42,9%) - amostra pequena mas na zona
# ambígua -> evidência CONFLITANTE, nunca adivinhada para nenhum dos dois lados.
r = infer_role(product(TIPO="VENDA"), {**ev_with(3, 4), "stock_in": {"000001": True}}, Context([]), "000001", "1")
assert r[0] == "UNKNOWN" and "MIXED" in r[1][0]
# O licor: 34 composições vs 22 vendas (60,7%) - maioria real, mas abaixo do limiar dominante
# (70%) -> também CONFLITANTE, nunca OWN_PRODUCTION por uma maioria fraca.
r = infer_role(product(TIPO="VENDA"), {**ev_with(34, 22), "stock_in": {"000001": True}}, Context([]), "000001", "1")
assert r[0] == "UNKNOWN" and "MIXED" in r[1][0]
# Suco com PROD_COZINHA=1 + RECEITA_INGREDIENTES: evidência forte e DIRETA (não é composição
# estatística) - continua OWN_PRODUCTION, o limiar de composição não se aplica a ela.
assert infer_role(product(PROD_COZINHA=1), {**EV, "recipes": {"000001"}}, Context([]), "000001", "1")[0] == "OWN_PRODUCTION"
# Uma maioria REAL e forte (90%, amostra suficiente) ainda prova produção própria.
r = infer_role(product(), ev_with(90, 10), Context([]), "000001", "1")
assert r[0] == "OWN_PRODUCTION" and r[1] == ["C000032.TIPO=COMPOSICAO"]
# Amostra insuficiente (< 5 eventos), mesmo 100% composição: não confia num histórico tão raso.
r = infer_role(product(TIPO="VENDA"), {**ev_with(2, 0), "stock_in": {"000001": True}}, Context([]), "000001", "1")
assert r[0] == "RESALE" # a evidência de composição é ignorada, cai para a evidência de venda real
# Tilápia/corvina: sem NENHUMA evidência de composição - continua revenda normalmente (o fix
# não pode quebrar o caso que já funcionava).
r = infer_role(product(TIPO="VENDA", CFOP=None), {**EV, "purchases": {"000001": [{}]}}, Context([]), "000001", "1")
assert r[0] == "RESALE"
# ---------------- executor guards ----------------
def load_executor():
spec = importlib.util.spec_from_file_location("fwe_local", ROOT / "scripts" / "firebird_write_executor.py")