"""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}