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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user