git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
122 lines
6.2 KiB
Python
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}
|