fix: KB dashboard search was applying the strict fiscal name-match rule (used to authorize automatic writes) to human browsing, so a natural query (e.g. missing the unit word) found nothing; now a separate, lenient, global, paginated server-side search — filter over the whole KB, then count/order/paginate. 1.3.6 (Hub only)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-27 10:33:45 -03:00
co-authored by Claude Sonnet 5
parent 0f5d27025d
commit fbff392ec9
22 changed files with 180 additions and 38 deletions
+39 -11
View File
@@ -100,23 +100,50 @@ def _candidate_cards(session, products) -> list[dict]:
return out
def kb_search(session, query: str, limit: int = 6) -> list[dict]:
from ..models import KbProduct
from . import kb as kbm
def kb_search_ids(session, query: str) -> list[int]:
"""GLOBAL, server-side match over the whole KB (never limited to a rendered page): GTIN exact, or every normalized query word present somewhere among the
product's known names (any alias row OR its own normalized_description - words can come from different aliases of the same product). Deliberately lenient
(a human exploring the KB, e.g. "COCA COLA LATA 350" without the unit, must still find "COCA-COLA ORIGINAL LATA 350 ML"); this is DIFFERENT from
`kb.alias_match`, the strict rule that authorizes an automatic fiscal write, which stays untouched."""
import re
from ..models import KbKnowledge, KbProduct
from . import kb as kbm, textnorm
q = (query or "").strip()
if not q:
return []
digits = "".join(ch for ch in q if ch.isdigit())
products = []
if len(digits) >= 8 and len(digits) == len(q.replace(" ", "")):
if len(digits) >= 8 and re.fullmatch(r"[\d\s.\-]+", q):
p = kbm.find_by_gtin(session, digits.zfill(14))
products = [p] if p else []
else:
products = kbm.name_candidates(session, q)[:limit]
return _candidate_cards(session, products)
return [p.id] if p else []
tokens = [t for t in textnorm.tokens(q) if t not in textnorm.MARKETING]
if not tokens:
return []
ids: set[int] | None = None
for token in tokens: # SQL ILIKE narrows the candidates (portable: Postgres in production, SQLite in tests); the exact whole-word
like = f"%{token}%" # check happens in Python so "ML" never matches inside "SIMPLES", etc.
matched = {pid for pid, val in session.execute(select(KbKnowledge.kb_product_id, KbKnowledge.value).where(KbKnowledge.knowledge_key == "alias", KbKnowledge.value.ilike(like))) if token in (val or "").split()}
matched |= {pid for pid, val in session.execute(select(KbProduct.id, KbProduct.normalized_description).where(KbProduct.normalized_description.ilike(like))) if token in (val or "").split()}
ids = matched if ids is None else (ids & matched)
if not ids:
return []
return sorted(ids)
def kb_summary(session, query: str | None = None) -> dict:
def kb_search(session, query: str, *, page: int = 1, page_size: int = 10) -> dict:
"""query/filter GLOBAL over the whole KB -> count -> order -> pagination (never the other way round)."""
from ..models import KbProduct
ids = kb_search_ids(session, query)
total = len(ids)
page = max(1, page)
pages = max(1, -(-total // page_size))
page = min(page, pages)
page_ids = ids[(page - 1) * page_size: page * page_size]
products = [session.get(KbProduct, i) for i in page_ids]
return {"results": _candidate_cards(session, products), "total": total, "page": page, "pages": pages, "page_size": page_size}
def kb_summary(session, query: str | None = None, page: int = 1) -> dict:
"""Compact Knowledge Base panel: known products, statuses of the NCM knowledge, hits today, and the latest conflicts with their evidence (no dashboard of its own)."""
from ..models import KbKnowledge, KbProduct
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
@@ -134,4 +161,5 @@ def kb_summary(session, query: str | None = None) -> dict:
"candidates": [{"ncm": i.value, "clients": sorted({c for c in (i.evidence or {}).get("clients", [])}), "documents": sum(int(l.get("docs", 0)) for l in ((i.evidence or {}).get("lineages") or {}).values()),
"providers": sorted(f for f in ((i.evidence or {}).get("families") or {}) if f.startswith("PROVIDER:"))} for i in items]})
return {"products": products, "verified": by.get("VERIFIED_KB", 0), "corroborated": by.get("CORROBORATED", 0) + by.get("EXTERNAL_CORROBORATED", 0), "observed": by.get("OBSERVED", 0),
"conflicts": by.get("CONFLICT", 0), "stale": by.get("STALE", 0), "hits_today": hits, "recent_conflicts": conflicts, "search": kb_search(session, query) if query else []}
"conflicts": by.get("CONFLICT", 0), "stale": by.get("STALE", 0), "hits_today": hits, "recent_conflicts": conflicts,
"search": kb_search(session, query, page=page) if query else {"results": [], "total": 0, "page": 1, "pages": 1, "page_size": 10}}
+1 -1
View File
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
GTI_FISCAL_VERSION = "1.3.5"
GTI_FISCAL_VERSION = "1.3.6"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
+2 -2
View File
@@ -159,10 +159,10 @@ def router(session_factory):
return {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
@routes.get("/", response_class=HTMLResponse)
def dashboard(request: Request, kb_q: str | None = None):
def dashboard(request: Request, kb_q: str | None = None, kb_page: int = 1):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try: return render(request, "dashboard.html", {"summary": dashboard_summary(session), "auth": auth_data, "kb_q": kb_q or "", "is_admin": _is_privileged(auth_data[1]), "csrf_token": request.cookies.get("gti_csrf", ""), "sources": provider_sources(session), "official": official_sources(session), "enrich_metrics": coverage_metrics(session), "kb": kb_summary(session, kb_q)})
try: return render(request, "dashboard.html", {"summary": dashboard_summary(session), "auth": auth_data, "kb_q": kb_q or "", "is_admin": _is_privileged(auth_data[1]), "csrf_token": request.cookies.get("gti_csrf", ""), "sources": provider_sources(session), "official": official_sources(session), "enrich_metrics": coverage_metrics(session), "kb": kb_summary(session, kb_q, kb_page)})
finally: session.close()
@routes.get("/agents", response_class=HTMLResponse)