Files
GTI_GERENCIADOR_FISCAL/fiscal/central/app/ai/kb.py
T
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

122 lines
6.2 KiB
Python

"""Product Knowledge Base service: identity resolution, versioned knowledge, conflicts.
Identity order (operator contract): 1. exact GTIN; 2. strong fingerprint; 3. semantic
fingerprint candidate; 4. unknown. Conflicting knowledge for the same key is kept
side by side and surfaced as KNOWLEDGE_CONFLICT — never picked by majority.
"""
from datetime import datetime, timezone
from sqlalchemy import func as sa_func, select
from ..models import KbKnowledge, KbObservation, KbProduct
EXACT, FINGERPRINT = "EXACT", "FINGERPRINT"
_AUTHORITATIVE = {"OFFICIAL_DATASET", "OPERATOR", "AI_PLUS_OFFICIAL_VALIDATION"}
class KnowledgeBase:
def __init__(self, session):
self.session = session
# ----- identity -----
def upsert_product(self, fingerprint, gtin=None, normalized_description="", brand=None, unit=None, package_quantity=None, category=None):
gtin = gtin or None
product = None
if gtin:
product = self.session.scalar(select(KbProduct).where(KbProduct.gtin == gtin))
if product is None:
product = self.session.scalar(select(KbProduct).where(KbProduct.fingerprint == fingerprint))
if product is None:
product = KbProduct(fingerprint=fingerprint, normalized_description=normalized_description or "")
self.session.add(product)
# identity upgrades (never downgrades)
if gtin and not product.gtin:
product.gtin = gtin
product.identity_kind = "GTIN"
if normalized_description and not product.normalized_description:
product.normalized_description = normalized_description
for field, value in (("brand", brand), ("unit", unit), ("package_quantity", package_quantity), ("category", category)):
if value and not getattr(product, field):
setattr(product, field, value)
self.session.flush()
return product
def resolve(self, gtin=None, fingerprint=None):
"""Returns (product|None, hit_kind). Cache-first: EXACT (GTIN) beats FINGERPRINT."""
if gtin:
product = self.session.scalar(select(KbProduct).where(KbProduct.gtin == gtin))
if product:
return product, EXACT
if fingerprint:
product = self.session.scalar(select(KbProduct).where(KbProduct.fingerprint == fingerprint))
if product:
return product, FINGERPRINT
return None, None
# ----- knowledge -----
def add_knowledge(self, kb_product_id, knowledge_key, value, source, confidence=0.0, evidence=None, model=None, prompt_version=None, dataset_versions=None):
value = str(value)
row = self.session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == kb_product_id, KbKnowledge.knowledge_key == knowledge_key, KbKnowledge.value == value))
if row is None:
row = KbKnowledge(kb_product_id=kb_product_id, knowledge_key=knowledge_key, value=value, source=source, confidence=confidence, evidence=evidence or {}, model=model, prompt_version=prompt_version, dataset_versions=dataset_versions or {})
self.session.add(row)
else:
# authoritative sources upgrade provenance/confidence of the same value; history preserved
if source in _AUTHORITATIVE:
row.source = source
row.confidence = max(row.confidence, confidence)
row.evidence = {**row.evidence, **(evidence or {})}
if dataset_versions:
row.dataset_versions = {**row.dataset_versions, **dataset_versions}
if source == "OFFICIAL_DATASET":
row.validated_at = datetime.now(timezone.utc)
self.session.flush()
return row
def current_knowledge(self, kb_product_id):
rows = self.session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == kb_product_id)).all()
by_key: dict = {}
for row in rows:
by_key.setdefault(row.knowledge_key, []).append(row)
result = {}
for key, entries in by_key.items():
values = {entry.value for entry in entries}
if len(values) > 1:
result[key] = {"conflict": True, "values": values}
else:
best = max(entries, key=lambda entry: entry.confidence)
result[key] = {"conflict": False, "value": best.value, "source": best.source, "confidence": best.confidence}
return result
# ----- observations -----
def record_observation(self, kb_product_id, tenant_id, company_key, erp_code, database_label, observed_fields, source="ERP_OBSERVATION"):
row = KbObservation(kb_product_id=kb_product_id, tenant_id=tenant_id, company_key=company_key, erp_code=erp_code, database_label=database_label, observed_fields=observed_fields or {}, source=source)
self.session.add(row); self.session.flush()
return row
def observation_summary(self, kb_product_id):
rows = self.session.scalars(select(KbObservation).where(KbObservation.kb_product_id == kb_product_id)).all()
agreement: dict = {}
for row in rows:
for field, value in (row.observed_fields or {}).items():
if value in (None, ""):
continue
bucket = agreement.setdefault(field, {})
bucket[str(value)] = bucket.get(str(value), 0) + 1
conflicts = {field: values for field, values in agreement.items() if len(values) > 1}
return {"observation_count": len(rows), "tenant_count": len({row.tenant_id for row in rows}), "agreement": agreement, "conflicts": conflicts}
# ----- stats -----
def stats(self):
products = self.session.scalar(select(sa_func.count()).select_from(KbProduct)) or 0
gtin_products = self.session.scalar(select(sa_func.count()).select_from(KbProduct).where(KbProduct.gtin.is_not(None))) or 0
conflicts = 0
for product_id in self.session.scalars(select(KbProduct.id)).all():
knowledge = self.current_knowledge(product_id)
conflicts += sum(1 for entry in knowledge.values() if entry.get("conflict"))
fingerprint_products = products - gtin_products
return {"products": products, "gtin_products": gtin_products, "fingerprint_products": fingerprint_products, "conflicts": conflicts}