chore: capture semantic pipeline baseline
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""AI semantic layer V1: fingerprint, KB, dedup, provider contract, budget guard, candidates."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, Dataset, NcmEntry, CestEntry, CestNcmEntry, TipiEntry
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _ncm_dataset(session, entries):
|
||||
dataset = Dataset(kind="NCM", provider="Siscomex", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=len(entries))
|
||||
session.add(dataset); session.flush()
|
||||
for code, description in entries:
|
||||
session.add(NcmEntry(dataset_id=dataset.id, code=code, description=description, valid_from="", valid_to=""))
|
||||
session.flush()
|
||||
return dataset
|
||||
|
||||
|
||||
# ---------------- fingerprint / identity ----------------
|
||||
|
||||
def test_fingerprint_is_stable_and_ignores_unstable_context():
|
||||
from central.app.ai.fingerprint import normalize_description, product_fingerprint, validate_gtin
|
||||
a = product_fingerprint("Coca-Cola 2LT", "COCA-COLA", "UN", "2")
|
||||
b = product_fingerprint("coca cola 2lt ", "coca-cola", "un", "2.0")
|
||||
assert a == b
|
||||
# fiscal/company context never participates in global identity
|
||||
c = product_fingerprint("Coca-Cola 2LT", "COCA-COLA", "UN", "2")
|
||||
assert a == c
|
||||
assert normalize_description("Água Mineral 500ml") == "AGUA MINERAL 500ML"
|
||||
assert validate_gtin("7891234567895") == "7891234567895" # valid GTIN-13
|
||||
assert validate_gtin("7891234567890") is None # bad checksum
|
||||
assert validate_gtin("123") is None
|
||||
|
||||
|
||||
# ---------------- knowledge base ----------------
|
||||
|
||||
def test_kb_exact_fingerprint_hit_and_conflict_without_majority():
|
||||
from central.app.ai.kb import KnowledgeBase, EXACT, FINGERPRINT
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
kb = KnowledgeBase(session)
|
||||
p1 = kb.upsert_product(fingerprint="f1", gtin="7891234567895", normalized_description="AGUA MINERAL 500ML")
|
||||
# same GTIN arrives with a different fingerprint -> same product, exact hit
|
||||
p2, hit = kb.resolve(gtin="7891234567895", fingerprint="other")
|
||||
assert p2.id == p1.id and hit == EXACT
|
||||
# same fingerprint, no GTIN -> fingerprint hit
|
||||
p3, hit = kb.resolve(gtin=None, fingerprint="f2")
|
||||
assert p3 is None and hit is None
|
||||
p4 = kb.upsert_product(fingerprint="f2", normalized_description="OUTRO")
|
||||
_, hit = kb.resolve(gtin=None, fingerprint="f2")
|
||||
assert hit == FINGERPRINT
|
||||
kb.add_knowledge(p1.id, "candidate_ncm", "22011000", source="OFFICIAL_DATASET", confidence=1.0)
|
||||
kb.add_knowledge(p1.id, "candidate_ncm", "22019000", source="OFFICIAL_DATASET", confidence=1.0)
|
||||
current = kb.current_knowledge(p1.id)
|
||||
assert current["candidate_ncm"]["conflict"] is True # no majority auto-pick
|
||||
assert current["candidate_ncm"]["values"] == {"22011000", "22019000"}
|
||||
stats = kb.stats()
|
||||
assert stats["products"] == 2 and stats["gtin_products"] == 1 and stats["conflicts"] == 1
|
||||
|
||||
|
||||
def test_kb_aggregates_observations_across_tenants():
|
||||
from central.app.ai.kb import KnowledgeBase
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
kb = KnowledgeBase(session)
|
||||
p = kb.upsert_product(fingerprint="f1", normalized_description="REFRIGERANTE 2L")
|
||||
kb.record_observation(p.id, tenant_id=1, company_key="MMMODAS", erp_code="000001", database_label="MMMODAS", observed_fields={"ncm": "22021000"})
|
||||
kb.record_observation(p.id, tenant_id=2, company_key="UNIAO", erp_code="000009", database_label="UNIAO", observed_fields={"ncm": "22021000"})
|
||||
agg = kb.observation_summary(p.id)
|
||||
assert agg["observation_count"] == 2 and agg["tenant_count"] == 2
|
||||
assert agg["agreement"]["ncm"] == {"22021000": 2} and agg["conflicts"] == {}
|
||||
|
||||
|
||||
# ---------------- work item dedup ----------------
|
||||
|
||||
def test_work_items_dedup_by_knowledge_needed():
|
||||
from central.app.ai.workitems import build_work_items
|
||||
questions = [
|
||||
{"QUESTION_ID": "Q1", "DATABASE": "MMMODAS", "COMPANY": "MMMODAS", "SCOPE": "NCM", "GROUP": "", "NCM": "33059000", "KEY": "ex_tipi", "CELLS": 10},
|
||||
{"QUESTION_ID": "Q2", "DATABASE": "UNIAO", "COMPANY": "UNIAO", "SCOPE": "NCM", "GROUP": "", "NCM": "33059000", "KEY": "ex_tipi", "CELLS": 5},
|
||||
{"QUESTION_ID": "Q3", "DATABASE": "RINCAO", "COMPANY": "RINCAO", "SCOPE": "GROUP", "GROUP": "000015", "KEY": "product_role", "CELLS": 40},
|
||||
{"QUESTION_ID": "Q4", "DATABASE": "CONSTRUCAO", "COMPANY": "CONSTRUCAO", "SCOPE": "PRODUCT", "GROUP": "", "NCM": "", "KEY": "ncm", "CELLS": 8, "QUESTION": "produto PRODUCT 000054", "DATABASE_CODE": "000054"},
|
||||
]
|
||||
items = build_work_items(questions, product_fingerprints={"CONSTRUCAO:000054": "fp-a"})
|
||||
# 4 raw questions -> 3 deduped work items (two ex_tipi questions for NCM 33059000 collapse)
|
||||
by_key = {i.dedup_key: i for i in items}
|
||||
assert len(items) == 3
|
||||
assert by_key["ex_tipi:33059000"].cells_covered == 15
|
||||
assert by_key["ex_tipi:33059000"].question_refs == ["Q1", "Q2"]
|
||||
assert by_key["product_role:RINCAO:000015"].cells_covered == 40
|
||||
assert by_key["invalid_ncm:fp-a"].cells_covered == 8
|
||||
# AI-ineligible keys never become work items
|
||||
assert all(i.task_type in ("INVALID_NCM", "CEST", "EX_TIPI", "PRODUCT_ROLE") for i in items)
|
||||
|
||||
|
||||
# ---------------- provider contract / structured output / budget ----------------
|
||||
|
||||
def test_structured_output_schema_and_baseline_provider():
|
||||
from central.app.ai.provider import BaselineProvider, result_from_dict, SemanticOutputError
|
||||
provider = BaselineProvider()
|
||||
item = type("W", (), {"task_type": "INVALID_NCM", "dedup_key": "invalid_ncm:fp-a", "payload": {"description": "AGUA MINERAL NATURAL 500ML"}})()
|
||||
candidates = [("22011000", "AGUAS MINERAIS NATURAIS", 0.75), ("22019000", "AGUAS ... OUTRAS", 0.25)]
|
||||
result = provider.select(item, candidates, dataset_versions={"ncm": "sha-ncm"})
|
||||
assert result.task_type == "INVALID_NCM" and result.selected_candidate == "22011000"
|
||||
assert result.candidate_ids_seen == ["22011000", "22019000"]
|
||||
assert result.model == "baseline-deterministic-v1" and result.prompt_version == "ai-semantic-v1"
|
||||
assert result.dataset_versions and result.engine_version
|
||||
as_dict = result.to_dict()
|
||||
assert result_from_dict(as_dict).selected_candidate == "22011000"
|
||||
# schema fail-closed: a provider answer outside the candidate list is invalid
|
||||
try:
|
||||
result_from_dict({**as_dict, "selected_candidate": "99999999"})
|
||||
raise AssertionError("must reject")
|
||||
except SemanticOutputError:
|
||||
pass
|
||||
# no candidates at all -> NONE, never invented
|
||||
none_result = provider.select(item, [])
|
||||
assert none_result.selected_candidate == "NONE"
|
||||
|
||||
|
||||
def test_budget_guard_blocks_run_over_limit():
|
||||
from central.app.ai.provider import BudgetGuard
|
||||
guard = BudgetGuard(max_run_cost_usd=1.0, price_in_per_m=1.0, price_out_per_m=2.0)
|
||||
plan = guard.estimate(items_in_tokens=600_000, items_out_tokens=300_000)
|
||||
assert plan["estimated_cost"] > 0
|
||||
assert guard.authorize(plan) is False # (600k*1 + 300k*2)/1M = 1.2 > 1.0
|
||||
assert guard.authorize(guard.estimate(100_000, 50_000)) is True
|
||||
|
||||
|
||||
# ---------------- deterministic candidates ----------------
|
||||
|
||||
def test_ncm_top_k_and_ex_tipi_candidates():
|
||||
from central.app.ai.candidates import ncm_top_k, ex_tipi_candidates_for_ncm, cest_candidates_for_ncm
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_ncm_dataset(session, [("22011000", "AGUAS MINERAIS NATURAIS"), ("22019000", "AGUAS MINERAIS GASEIFICADAS OUTRAS"), ("08043000", "ABACAXIS FRESCOS")])
|
||||
top = ncm_top_k(session, "AGUA MINERAL NATURAL", k=2)
|
||||
assert [c[0] for c in top] == ["22011000", "22019000"]
|
||||
dataset = Dataset(kind="TIPI_IPI", provider="RFB", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=2)
|
||||
session.add(dataset); session.flush()
|
||||
session.add(TipiEntry(dataset_id=dataset.id, ncm="33059000", ex=None, description="outros", ipi_rate=None, ipi_rate_text="NT", source_row=1))
|
||||
session.add(TipiEntry(dataset_id=dataset.id, ncm="33059000", ex="01", description="preparacoes para alisar", ipi_rate=None, ipi_rate_text="NT", source_row=2))
|
||||
cest_dataset = Dataset(kind="CEST", provider="CONFAZ", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(cest_dataset); session.flush()
|
||||
cest = CestEntry(dataset_id=cest_dataset.id, code="03.999.99", description="x", annex="II")
|
||||
session.add(cest); session.flush()
|
||||
session.add(CestNcmEntry(cest_entry_id=cest.id, ncm_code="22011000"))
|
||||
session.commit()
|
||||
assert [e.ex for e in ex_tipi_candidates_for_ncm(session, "33059000")] == ["01"]
|
||||
assert [c.code for c in cest_candidates_for_ncm(session, "22011000")] == ["03.999.99"]
|
||||
|
||||
|
||||
def test_kb_persist_roundtrip_models():
|
||||
from central.app.models import KbProduct, KbKnowledge, KbObservation, TenantProductContext, AiWorkItem, AiRun
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
p = KbProduct(fingerprint="f", normalized_description="D")
|
||||
session.add(p); session.flush()
|
||||
session.add(KbKnowledge(kb_product_id=p.id, knowledge_key="candidate_ncm", value="22011000", source="OFFICIAL_DATASET", confidence=1.0))
|
||||
session.add(KbObservation(kb_product_id=p.id, tenant_id=1, company_key="C", erp_code="1", database_label="C", observed_fields={}))
|
||||
session.add(TenantProductContext(tenant_id=1, company_key="C", erp_code="1", kb_product_id=p.id))
|
||||
session.add(AiWorkItem(task_type="CEST", dedup_key="cest:1", payload={}, question_refs=[], cells_covered=1))
|
||||
session.add(AiRun(provider="baseline", model="m", prompt_version="v1", input_tokens=1, output_tokens=1, cost_usd=0.0, status="DONE", results=[]))
|
||||
session.commit()
|
||||
assert session.query(KbProduct).count() == 1 and session.query(AiWorkItem).count() == 1
|
||||
@@ -121,6 +121,18 @@ def test_cfop_entrada_needs_company_entry_scope_then_derives():
|
||||
assert run(p=product(CFOP_ENTRADA="1102"))["CFOP_ENTRADA"]["state"] == ALREADY
|
||||
|
||||
|
||||
def test_cfop_entrada_mixed_scope_is_operation_dependent_and_never_invents_a_cfop():
|
||||
mixed = [{"scope": "COMPANY", "scope_key": "", "key": "entry_scope", "value": "MIXED_OR_DYNAMIC"}]
|
||||
r = run(p=product(CFOP_ENTRADA=None), ctx=mixed)
|
||||
e = r["CFOP_ENTRADA"]
|
||||
assert e["state"] == MANUAL and e["proposed"] is None and e.get("input_key") is None
|
||||
assert e["reason"] == "CFOP_ENTRADA_OPERATION_DEPENDENT_MIXED_SCOPE"
|
||||
r2 = run(p=product(CFOP_ENTRADA="2102"), ctx=mixed)
|
||||
assert r2["CFOP_ENTRADA"]["state"] == ALREADY and r2["CFOP_ENTRADA"]["reason"] == "DECLARED_VALID_OPERATION_DEPENDENT_MIXED_SCOPE"
|
||||
from central.app.context_keys import validate_entry
|
||||
assert validate_entry({"scope": "COMPANY", "key": "entry_scope", "value": "MIXED_OR_DYNAMIC"})[0]["value"] == "MIXED_OR_DYNAMIC"
|
||||
|
||||
|
||||
def test_icms_code_fields_follow_the_regime():
|
||||
r1 = run()
|
||||
assert r1["CST"]["state"] == NA and r1["CST_ENTRADA"]["state"] == NA and r1["CSOSN"]["state"] == ALREADY
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Real LLM provider contract: strict answers, batching safety, cache, metrics attribution."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, AiEvalCache
|
||||
|
||||
|
||||
def _item(work_item_id="w1", candidates=None):
|
||||
from central.app.ai.llm_provider import LlmEvalItem
|
||||
return LlmEvalItem(work_item_id=work_item_id, description="AGUA MINERAL 500ML", normalized_description="AGUA MINERAL 500ML",
|
||||
brand=None, unit="ML", segment="mercado", candidates=candidates or [("22011000", "AGUAS MINERAIS", 0.9), ("22019000", "AGUAS OUTRAS", 0.4)])
|
||||
|
||||
|
||||
def _answer(work_item_id="w1", ncm="22011000", confidence=0.9, abstain=False):
|
||||
return {"work_item_id": work_item_id, "selected_ncm": ncm, "confidence": confidence, "abstain": abstain, "reason_short": "desc match"}
|
||||
|
||||
|
||||
def test_validate_answer_accepts_only_candidate_or_abstain():
|
||||
from central.app.ai.llm_provider import LlmAnswerError, validate_answer
|
||||
item = _item()
|
||||
assert validate_answer(_answer(), item)["selected_ncm"] == "22011000"
|
||||
abstain = validate_answer(_answer(ncm=None, abstain=True), item)
|
||||
assert abstain["abstain"] is True and abstain["selected_ncm"] is None
|
||||
for bad in (_answer(ncm="99999999"), _answer(work_item_id="other"), _answer(ncm=None), _answer(confidence=1.5), _answer(ncm="22011000", abstain=True)):
|
||||
try:
|
||||
validate_answer(bad, item)
|
||||
raise AssertionError("must reject")
|
||||
except LlmAnswerError:
|
||||
pass
|
||||
|
||||
|
||||
def test_provider_batch_requires_1_to_1_answers():
|
||||
from central.app.ai.llm_provider import LlmAnswerError, OpenAICompatibleProvider
|
||||
|
||||
def transport(payload):
|
||||
content = json.dumps([_answer(), _answer()]) # two answers for one item
|
||||
return {"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}}
|
||||
|
||||
provider = OpenAICompatibleProvider(api_key="test-key", model="m", transport=transport)
|
||||
try:
|
||||
provider.select_batch([_item()])
|
||||
raise AssertionError("must reject")
|
||||
except LlmAnswerError:
|
||||
pass
|
||||
assert provider.http_calls == 1 # contract violations are NOT retried
|
||||
|
||||
|
||||
def test_provider_retries_transport_failures_and_counts_usage():
|
||||
from central.app.ai.llm_provider import OpenAICompatibleProvider
|
||||
calls = {"n": 0}
|
||||
|
||||
def transport(payload):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("boom")
|
||||
content = json.dumps([_answer()])
|
||||
return {"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 100, "completion_tokens": 20, "prompt_tokens_details": {"cached_tokens": 50}}}
|
||||
|
||||
provider = OpenAICompatibleProvider(api_key="k", model="m", transport=transport)
|
||||
answers = provider.select_batch([_item()])
|
||||
assert answers[0]["selected_ncm"] == "22011000"
|
||||
assert provider.http_calls == 2 and provider.retries == 1
|
||||
assert provider.usage == {"input_tokens": 100, "output_tokens": 20, "cached_tokens": 50}
|
||||
|
||||
|
||||
def test_prompt_hides_gold_and_carries_candidates():
|
||||
from central.app.ai.llm_provider import candidate_set_hash, input_fingerprint, item_prompt_payload
|
||||
item = _item()
|
||||
payload = item_prompt_payload(item)
|
||||
assert "22011000" in json.dumps(payload) # candidates present
|
||||
assert item.normalized_description in json.dumps(payload)
|
||||
assert len(candidate_set_hash(item)) == 64 and len(input_fingerprint(item)) == 64
|
||||
changed = _item(candidates=[("11111111", "X", 0.1)])
|
||||
assert candidate_set_hash(changed) != candidate_set_hash(item)
|
||||
|
||||
|
||||
def test_eval_cache_roundtrip_deduplicates():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
row = AiEvalCache(input_fingerprint="f", candidate_set_hash="c", prompt_version="p", model="m", dataset_version_ncm="d", phase="gold", answer={"selected_ncm": "22011000"}, gold_ncm="22011000")
|
||||
session.add(row); session.commit()
|
||||
existing = session.scalar(select(AiEvalCache).where(AiEvalCache.input_fingerprint == "f", AiEvalCache.candidate_set_hash == "c", AiEvalCache.prompt_version == "p", AiEvalCache.model == "m", AiEvalCache.dataset_version_ncm == "d"))
|
||||
assert existing.answer["selected_ncm"] == "22011000"
|
||||
|
||||
|
||||
def test_metrics_and_error_attribution():
|
||||
from central.app.ai.eval_metrics import attribution, confidence_buckets, metrics
|
||||
rows = [
|
||||
{"gold_in_candidates": True, "selected": "A", "gold": "A", "confidence": 0.99, "invalid": False},
|
||||
{"gold_in_candidates": True, "selected": "B", "gold": "A", "confidence": 0.97, "invalid": False},
|
||||
{"gold_in_candidates": True, "selected": None, "gold": "A", "confidence": 0.5, "invalid": False},
|
||||
{"gold_in_candidates": False, "selected": "B", "gold": "A", "confidence": 0.9, "invalid": False},
|
||||
{"gold_in_candidates": True, "selected": "zzz", "gold": "A", "confidence": 0.9, "invalid": True},
|
||||
]
|
||||
m = metrics(rows)
|
||||
assert m["TOTAL"] == 5 and m["CORRECT"] == 1 and m["WRONG"] == 2 and m["ABSTAIN"] == 1 and m["INVALID"] == 1
|
||||
assert m["GOLD_IN_CANDIDATES"] == 4
|
||||
assert abs(m["SELECTION_ACCURACY_WHEN_GOLD_AVAILABLE"] - 1 / 3) < 1e-4 # correct / (correct+wrong) among valid answers
|
||||
assert abs(m["ABSTENTION_RATE"] - 0.2) < 1e-9
|
||||
a = attribution(rows)
|
||||
assert a["GOLD_NOT_IN_CANDIDATES"] == 1 and a["GOLD_IN_CANDIDATES_BUT_WRONG"] == 1 and a["GOLD_IN_CANDIDATES_BUT_ABSTAIN"] == 1
|
||||
buckets = confidence_buckets(rows)
|
||||
assert buckets[">=0.99"] == {"total": 1, "correct": 1} and buckets["0.95-0.9899"] == {"total": 1, "correct": 0}
|
||||
|
||||
|
||||
def test_zai_model_selection_rules():
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("eval_runner", "central/scripts/run_real_llm_ncm_eval.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
available = ["glm-5.3", "glm-5.3-flash", "glm-4.7", "other-vendor-x"]
|
||||
assert module._select_model(available, "glm-5.3") == ("glm-5.3", "requested")
|
||||
# requested missing -> current GLM flash fallback, never another vendor, never invented
|
||||
assert module._select_model(available, "glm-9.9") == ("glm-5.3-flash", "fallback_glm_flash")
|
||||
only_generic = ["glm-4", "glm-4.7"]
|
||||
assert module._select_model(only_generic, "glm-9.9") == ("glm-4.7", "fallback_glm_latest")
|
||||
assert module._select_model(["gpt-x", "claude-y"], "glm-5.3") == (None, "no_glm_available")
|
||||
assert module._diagnose_http(401) == "credential invalid (AI_API_KEY)"
|
||||
|
||||
|
||||
def test_budget_guard_unknown_cost_limited_run():
|
||||
from central.app.ai.provider import BudgetGuard
|
||||
guard = BudgetGuard(max_run_cost_usd=5.0, price_in_per_m=None, price_out_per_m=None)
|
||||
plan = guard.estimate(1000, 100)
|
||||
assert plan["estimated_cost"] is None and plan["cost_unknown"] is True
|
||||
assert guard.authorize(plan) is False # residual runs refused without prices
|
||||
assert guard.authorize(plan, allow_unknown_cost=True) is True # explicitly limited GOLD benchmark may opt in
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Semantic candidate retrieval V2: hierarchical official corpus + KB-neighbor bridge + hybrid ranking."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, Dataset, KbKnowledge, KbObservation, KbProduct, NcmEntry, Tenant
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _seed(session):
|
||||
ncm = Dataset(kind="NCM", provider="Siscomex", sha256="n" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=7)
|
||||
session.add(ncm); session.flush()
|
||||
rows = [
|
||||
("22", "BEBIDAS, LIQUIDOS ALCOOLICOS E VINAGRES"),
|
||||
("2202", "AGUAS, INCLUIDAS AS AGUAS GASEIFICADAS E MINERAIS"),
|
||||
("2202.10", "AGUAS MINERAIS E GASEIFICADAS"),
|
||||
("22021000", "AGUAS MINERAIS NAO GASEIFICADAS"),
|
||||
("61", "VESTUARIO E SEUS ACESSORIOS, DE MALHA"),
|
||||
("6105", "CAMISAS DE MALHA, DE USO MASCULINO"),
|
||||
("61051000", "DE ALGODAO"),
|
||||
]
|
||||
for code, description in rows:
|
||||
session.add(NcmEntry(dataset_id=ncm.id, code=code.replace(".", ""), description=description, valid_from="", valid_to=""))
|
||||
session.flush()
|
||||
# two tenants already selling mineral water with a validated NCM
|
||||
tenant_a, tenant_b = Tenant(name="A"), Tenant(name="B")
|
||||
session.add_all([tenant_a, tenant_b]); session.flush()
|
||||
p1 = KbProduct(fingerprint="f1", normalized_description="AGUA MINERAL SEM GAS 500ML")
|
||||
p2 = KbProduct(fingerprint="f2", normalized_description="AGUA MINERAL GARRAFA 1L")
|
||||
p3 = KbProduct(fingerprint="f3", normalized_description="CAMISA MASCULINA MALHA ALGODAO")
|
||||
session.add_all([p1, p2, p3]); session.flush()
|
||||
for product, ncm_value, tenant in ((p1, "22021000", tenant_a), (p2, "22021000", tenant_b), (p3, "61051000", tenant_a)):
|
||||
session.add(KbKnowledge(kb_product_id=product.id, knowledge_key="validated_ncm", value=ncm_value, source="OFFICIAL_DATASET", confidence=1.0))
|
||||
session.add(KbObservation(kb_product_id=p1.id, tenant_id=tenant_a.id, company_key="A", erp_code="1", database_label="A", observed_fields={}))
|
||||
session.add(KbObservation(kb_product_id=p2.id, tenant_id=tenant_b.id, company_key="B", erp_code="2", database_label="B", observed_fields={}))
|
||||
session.add(KbObservation(kb_product_id=p3.id, tenant_id=tenant_a.id, company_key="A", erp_code="3", database_label="A", observed_fields={}))
|
||||
session.commit()
|
||||
return ncm.sha256
|
||||
|
||||
|
||||
def test_enriched_corpus_carries_ancestor_context():
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
doc = corpus.document("22021000")
|
||||
# chapter + position + subposition context enriches the very short legal text (stemmed)
|
||||
assert "BEBIDA" in doc and "AGUA" in doc
|
||||
assert "VESTUARIO" not in doc
|
||||
|
||||
|
||||
def test_hierarchical_retrieval_prefers_right_positions():
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus, hierarchical_candidates
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
codes = [code for code, _, _ in hierarchical_candidates(corpus, "AGUA MINERAL", k=2)]
|
||||
assert codes[0] == "22021000"
|
||||
|
||||
|
||||
def test_kb_neighbor_bridge_maps_commercial_to_ncm():
|
||||
from central.app.ai.retrieval import KbNeighborIndex, kb_neighbor_candidates
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
neighbors = kb_neighbor_candidates(index, "AGUA MINERAL PET 500ML", k=5)
|
||||
assert neighbors and neighbors[0][0] == "22021000"
|
||||
# cross-tenant agreement is reported, never auto-majority
|
||||
assert neighbors[0][3] >= 2
|
||||
|
||||
|
||||
def test_hybrid_merges_official_and_kb_rankings():
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus, KbNeighborIndex, hybrid_candidates
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
hybrid = hybrid_candidates(corpus, index, "CAMISA MASCULINA ALGODAO", k=2)
|
||||
assert hybrid[0][0] == "61051000"
|
||||
scores = hybrid_candidates(corpus, index, "AGUA MINERAL SEM GAS", k=2)
|
||||
assert scores[0][0] == "22021000"
|
||||
|
||||
|
||||
def test_lexicon_expands_abbreviations_supported_by_kb():
|
||||
from central.app.ai.retrieval import derive_abbreviation_lexicon, expand_stems
|
||||
corpus = {
|
||||
"REFRIGERANTE COCA 2L": "22021000",
|
||||
"REFRI GUARANA 1L": "22021000",
|
||||
"REFRI LARANJA 350ML": "22021000",
|
||||
"CAMISA POLO ALGODAO": "61051000",
|
||||
"CAM POLO MASC": "61051000",
|
||||
"CAM POLO AZUL": "61051000",
|
||||
"BOTA COURO MASC": "64039900",
|
||||
}
|
||||
lexicon = derive_abbreviation_lexicon(corpus, min_support=2)
|
||||
assert lexicon.get("REFRI") == "REFRIGERANTE"
|
||||
assert lexicon.get("CAM") == "CAMISA"
|
||||
expanded = expand_stems({"REFRI", "COLA"}, lexicon)
|
||||
assert "REFRIGERANTE" in expanded
|
||||
|
||||
|
||||
def test_commercial_tokens_strip_units_and_sizes():
|
||||
from central.app.ai.retrieval import commercial_tokens
|
||||
tokens = commercial_tokens("CREME TRATAMENTO NOVEX 1KG CX 12 COR AZUL TAM M REF 453")
|
||||
assert "NOVEX" in tokens and "CREME" in tokens and "TRATAMENTO" in tokens
|
||||
assert "1KG" not in tokens and "CX" not in tokens and "COR" not in tokens and "TAM" not in tokens
|
||||
assert "AZUL" not in tokens and "REF" not in tokens and "453" not in tokens
|
||||
assert "12" not in tokens
|
||||
Reference in New Issue
Block a user