121 lines
5.8 KiB
Python
121 lines
5.8 KiB
Python
"""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}
|