"""Central product Knowledge Base access (same PostgreSQL, existing kb_* tables) with per-field status. Trust model (docs/product_enrichment.md): - A provider value or a client observation is EVIDENCE. A field becomes VERIFIED_KB only when the official dataset confirms it AND at least two INDEPENDENT evidence families agree with zero conflict (distinct providers each count once; client observations count once and only with >= 2 distinct clients). - Human edits never enter the KB (HUMAN_OVERRIDDEN is per-installation protection, not knowledge). Company-specific fiscal decisions (CSOSN, CFOP, ...) never live here. """ from __future__ import annotations import hashlib from datetime import datetime, timezone from sqlalchemy import select from ..models import KbKnowledge, KbProduct from . import textnorm UNVERIFIED, EVIDENCE_ONLY, VERIFIED_KB, CONFLICT, STALE = "UNVERIFIED", "EVIDENCE_ONLY", "VERIFIED_KB", "CONFLICT", "STALE" CLIENT_FAMILY = "CLIENT_OBSERVATION" def _now(): return datetime.now(timezone.utc) def gtin_fingerprint(key: str) -> str: return hashlib.sha256(f"gtin:{key}".encode()).hexdigest() def find_by_gtin(session, key: str | None) -> KbProduct | None: return session.scalar(select(KbProduct).where(KbProduct.gtin == key)) if key else None def name_candidates(session, description) -> list[KbProduct]: """Exact-fingerprint matches (0, 1 or several).""" fp = textnorm.fingerprint(description) if not fp: return [] ids = list(session.scalars(select(KbKnowledge.kb_product_id).where(KbKnowledge.knowledge_key == "name_fingerprint", KbKnowledge.value == fp).distinct())) return [p for p in (session.get(KbProduct, i) for i in ids) if p] def upsert_product(session, key: str | None, description, *, brand=None, quantity=None) -> KbProduct: product = find_by_gtin(session, key) if product is None: fp = gtin_fingerprint(key) if key else hashlib.sha256(f"name:{textnorm.fingerprint(description)}".encode()).hexdigest() product = session.scalar(select(KbProduct).where(KbProduct.fingerprint == fp)) if product is None: product = KbProduct(fingerprint=fp, gtin=key, identity_kind="GTIN" if key else "FINGERPRINT", normalized_description=textnorm.normalized(description)) session.add(product) session.flush() if description and not product.normalized_description: product.normalized_description = textnorm.normalized(description) if brand and not product.brand: product.brand = brand if quantity and not product.package_quantity: product.package_quantity = str(quantity)[:32] fp = textnorm.fingerprint(description) if fp: add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=EVIDENCE_ONLY) return product def add_evidence(session, product: KbProduct, key: str, value: str, source: str, *, family: str, client_label: str | None = None, status: str = EVIDENCE_ONLY, info: dict | None = None) -> KbKnowledge: row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key, KbKnowledge.value == value)) if row is None: row = KbKnowledge(kb_product_id=product.id, knowledge_key=key, value=value, source=source[:32], status=status, evidence={"families": {}, "clients": [], "info": info or {}}) session.add(row) session.flush() evidence = dict(row.evidence or {}) families = dict(evidence.get("families") or {}) families[family] = _now().isoformat() clients = list(evidence.get("clients") or []) if client_label and client_label not in clients: clients.append(client_label) row.evidence = {**evidence, "families": families, "clients": clients, "info": {**(evidence.get("info") or {}), **(info or {})}} return row def independent_count(rows) -> tuple[int, list[str]]: families = {} clients: set[str] = set() for row in rows: ev = row.evidence or {} for fam in (ev.get("families") or {}): if fam.startswith("PROVIDER:"): families[fam] = True elif fam == CLIENT_FAMILY: clients.update(ev.get("clients") or []) names = sorted(families) if len(clients) >= 2: names.append(f"{CLIENT_FAMILY}(x{len(clients)})") return len(names), names def evaluate(session, product: KbProduct, key: str, is_official) -> dict: """Recomputes and PERSISTS the status of one field. Returns {value, status, families, candidates, rejected}.""" rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key))) if not rows: return {"value": None, "status": UNVERIFIED, "families": [], "candidates": [], "rejected": []} valid = [r for r in rows if is_official(r.value)] rejected = [r.value for r in rows if not is_official(r.value)] values = sorted({r.value for r in valid}) if len(values) > 1: for r in rows: r.status = CONFLICT return {"value": None, "status": CONFLICT, "families": [], "candidates": values, "rejected": rejected} if not values: for r in rows: r.status = EVIDENCE_ONLY return {"value": None, "status": EVIDENCE_ONLY, "families": [], "candidates": [], "rejected": rejected} chosen = [r for r in valid if r.value == values[0]] count, names = independent_count(chosen) already = any(r.status == VERIFIED_KB and r.validated_at for r in chosen) status = VERIFIED_KB if (count >= 2 or already) else EVIDENCE_ONLY for r in rows: r.status = status if r.value == values[0] else EVIDENCE_ONLY if status == VERIFIED_KB and r.value == values[0] and not r.validated_at: r.validated_at = _now() return {"value": values[0], "status": status, "families": names, "candidates": values, "rejected": rejected}