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
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "gti-fiscal-agent" name = "gti-fiscal-agent"
version = "1.3.5" version = "1.3.6"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"] dependencies = ["httpx>=0.27,<1"]
+3 -3
View File
@@ -107,7 +107,7 @@ class Config:
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5")) self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib. # RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB))) self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
self.agent_version = env.get("AGENT_VERSION", "1.3.5") self.agent_version = env.get("AGENT_VERSION", "1.3.6")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1) self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite")) self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for # Pilot write authorization: both must be explicitly set by the installer for
@@ -349,7 +349,7 @@ def execute_job(job, config, executor, state, env):
"fiscal_context": fiscal_context, "fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply, "rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None), "fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.3.5", "release_version": env.get("AGENT_VERSION") or "1.3.6",
"products_total": len(products), "products_total": len(products),
"products_changed": len(to_process), "products_changed": len(to_process),
"products_processed": summary["processed"], "products_processed": summary["processed"],
@@ -520,7 +520,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env): def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed.""" """Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.5") report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.6")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False)) logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False)) print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS": if report["PREFLIGHT"] != "PASS":
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
AGENT_VERSION_DEFAULT = "1.3.5" AGENT_VERSION_DEFAULT = "1.3.6"
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD") REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest") REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY") FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
+39 -11
View File
@@ -100,23 +100,50 @@ def _candidate_cards(session, products) -> list[dict]:
return out return out
def kb_search(session, query: str, limit: int = 6) -> list[dict]: def kb_search_ids(session, query: str) -> list[int]:
from ..models import KbProduct """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
from . import kb as kbm 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() q = (query or "").strip()
if not q: if not q:
return [] return []
digits = "".join(ch for ch in q if ch.isdigit()) digits = "".join(ch for ch in q if ch.isdigit())
products = [] if len(digits) >= 8 and re.fullmatch(r"[\d\s.\-]+", q):
if len(digits) >= 8 and len(digits) == len(q.replace(" ", "")):
p = kbm.find_by_gtin(session, digits.zfill(14)) p = kbm.find_by_gtin(session, digits.zfill(14))
products = [p] if p else [] return [p.id] if p else []
else: tokens = [t for t in textnorm.tokens(q) if t not in textnorm.MARKETING]
products = kbm.name_candidates(session, q)[:limit] if not tokens:
return _candidate_cards(session, products) 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).""" """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 from ..models import KbKnowledge, KbProduct
today = datetime.now(timezone.utc).strftime("%Y-%m-%d") 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()), "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]}) "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), 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 once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py, are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump.""" 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 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} return {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
@routes.get("/", response_class=HTMLResponse) @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) session, auth_data, redirect = page_auth(request)
if redirect: return redirect 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() finally: session.close()
@routes.get("/agents", response_class=HTMLResponse) @routes.get("/agents", response_class=HTMLResponse)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "gti-fiscal-hub" name = "gti-fiscal-hub"
version = "1.3.5" version = "1.3.6"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"fastapi>=0.115,<1", "fastapi>=0.115,<1",
+4 -2
View File
@@ -30,10 +30,12 @@
</section> </section>
<section class="panel" id="kb-resumo"><div class="panel-title"><div><h2>Base de conhecimento de produtos</h2><p class="muted">Evidência local, histórica e externa; nada vira verdade fiscal sem regra de promoção.</p></div></div> <section class="panel" id="kb-resumo"><div class="panel-title"><div><h2>Base de conhecimento de produtos</h2><p class="muted">Evidência local, histórica e externa; nada vira verdade fiscal sem regra de promoção.</p></div></div>
<div class="coverage"><div class="coverage-item"><strong>{{ kb.products }}</strong><span>Produtos conhecidos</span></div><div class="coverage-item"><strong>{{ kb.verified }}</strong><span>GTINs verificados</span></div><div class="coverage-item"><strong>{{ kb.corroborated }}</strong><span>Corroborados</span></div><div class="coverage-item"><strong>{{ kb.conflicts }}</strong><span>Conflitos</span></div><div class="coverage-item"><strong>{{ kb.hits_today }}</strong><span>Resolvidos pela base hoje</span></div></div> <div class="coverage"><div class="coverage-item"><strong>{{ kb.products }}</strong><span>Produtos conhecidos</span></div><div class="coverage-item"><strong>{{ kb.verified }}</strong><span>GTINs verificados</span></div><div class="coverage-item"><strong>{{ kb.corroborated }}</strong><span>Corroborados</span></div><div class="coverage-item"><strong>{{ kb.conflicts }}</strong><span>Conflitos</span></div><div class="coverage-item"><strong>{{ kb.hits_today }}</strong><span>Resolvidos pela base hoje</span></div></div>
<form method="get" action="/" class="filters compact-filters"><label>Buscar produto na base (EAN ou nome)<input name="kb_q" value="{{ kb_q or '' }}" placeholder="ex.: COCA COLA LATA 350"></label><button class="secondary" type="submit">Buscar</button></form> <form method="get" action="/" class="filters compact-filters"><label>Buscar produto na base (EAN ou nome)<input name="kb_q" value="{{ kb_q or '' }}" placeholder="ex.: COCA COLA LATA 350"></label><button class="secondary" type="submit">Buscar</button>{% if kb_q %}<small class="muted">{{ kb.search.total }} resultado(s) em toda a base{% if kb.search.pages > 1 %} · página {{ kb.search.page }}/{{ kb.search.pages }}{% endif %}</small>{% endif %}</form>
{% for c in kb.search %}<div class="list-row"><span><b>{{ c.gtin }}</b> {{ c.description }} · <em class="badge">{{ c.status }}</em> {% for c in kb.search.results %}<div class="list-row"><span><b>{{ c.gtin }}</b> {{ c.description }} · <em class="badge">{{ c.status }}</em>
<small>{% for x in c.candidates %}NCM {{ x.ncm }} — <i>{{ x.official_text or "fora do dataset oficial vigente" }}</i> ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}{% if x.attested %}, decidido por {{ x.attested }}{% endif %}){% if is_admin and x.official_text and c.status != "VERIFIED_KB" %} <small>{% for x in c.candidates %}NCM {{ x.ncm }} — <i>{{ x.official_text or "fora do dataset oficial vigente" }}</i> ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}{% if x.attested %}, decidido por {{ x.attested }}{% endif %}){% if is_admin and x.official_text and c.status != "VERIFIED_KB" %}
<form method="post" action="/kb/attest" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="gtin" value="{{ c.gtin_key }}"><input type="hidden" name="ncm" value="{{ x.ncm }}"><button class="secondary" type="submit" title="Decisão fiscal de um administrador: este é o NCM correto deste EAN">Confirmar este NCM</button></form>{% endif %}<br>{% endfor %}</small></span></div>{% endfor %} <form method="post" action="/kb/attest" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="gtin" value="{{ c.gtin_key }}"><input type="hidden" name="ncm" value="{{ x.ncm }}"><button class="secondary" type="submit" title="Decisão fiscal de um administrador: este é o NCM correto deste EAN">Confirmar este NCM</button></form>{% endif %}<br>{% endfor %}</small></span></div>{% endfor %}
{% if kb_q and kb.search.total == 0 %}<p class="empty-state">Nenhum produto encontrado para "{{ kb_q }}".</p>{% endif %}
{% if kb.search.pages > 1 %}<nav class="pagination" aria-label="Paginação da busca na base">{% if kb.search.page > 1 %}<a href="/?kb_q={{ kb_q|urlencode }}&amp;kb_page={{ kb.search.page - 1 }}#kb-resumo">Anterior</a>{% endif %}<span>Página {{ kb.search.page }} de {{ kb.search.pages }}</span>{% if kb.search.page < kb.search.pages %}<a href="/?kb_q={{ kb_q|urlencode }}&amp;kb_page={{ kb.search.page + 1 }}#kb-resumo">Próxima</a>{% endif %}</nav>{% endif %}
{% for c in kb.recent_conflicts %}<div class="list-row"><span><b>{{ c.gtin }}</b> {{ c.description }}<small>NCMs divergentes, nada é preenchido: {% for x in c.candidates %}{{ x.ncm }} ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}){% if not loop.last %} × {% endif %}{% endfor %}</small></span></div>{% endfor %}</section> {% for c in kb.recent_conflicts %}<div class="list-row"><span><b>{{ c.gtin }}</b> {{ c.description }}<small>NCMs divergentes, nada é preenchido: {% for x in c.candidates %}{{ x.ncm }} ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}){% if not loop.last %} × {% endif %}{% endfor %}</small></span></div>{% endfor %}</section>
<section class="panel" id="enriquecimento-resumo"><div class="panel-title"><div><h2>Enriquecimento de produtos novos</h2><p class="muted">Cobertura medida sobre os produtos novos acompanhados (o catálogo histórico não entra).</p></div></div> <section class="panel" id="enriquecimento-resumo"><div class="panel-title"><div><h2>Enriquecimento de produtos novos</h2><p class="muted">Cobertura medida sobre os produtos novos acompanhados (o catálogo histórico não entra).</p></div></div>
{% if enrich_metrics.tracked %}<div class="coverage">{% for key,label in [("GTIN_VALID_RATE","Com GTIN válido"),("KB_HIT_RATE","Resolvidos pela base"),("PRODUCT_FOUND_RATE","Produto identificado"),("NCM_AUTO_FILL_SAFE_RATE","NCM preenchível com segurança"),("AMBIGUOUS_RATE","Ambíguos"),("CONFLICT_RATE","Em conflito")] %}<div class="coverage-item"><strong>{{ enrich_metrics[key] if enrich_metrics[key] is not none else "—" }}{% if enrich_metrics[key] is not none %}%{% endif %}</strong><span>{{ label }}</span></div>{% endfor %}</div>{% else %}<p class="empty-state">Nenhum produto novo acompanhado ainda.</p>{% endif %}</section> {% if enrich_metrics.tracked %}<div class="coverage">{% for key,label in [("GTIN_VALID_RATE","Com GTIN válido"),("KB_HIT_RATE","Resolvidos pela base"),("PRODUCT_FOUND_RATE","Produto identificado"),("NCM_AUTO_FILL_SAFE_RATE","NCM preenchível com segurança"),("AMBIGUOUS_RATE","Ambíguos"),("CONFLICT_RATE","Em conflito")] %}<div class="coverage-item"><strong>{{ enrich_metrics[key] if enrich_metrics[key] is not none else "—" }}{% if enrich_metrics[key] is not none %}%{% endif %}</strong><span>{{ label }}</span></div>{% endfor %}</div>{% else %}<p class="empty-state">Nenhum produto novo acompanhado ainda.</p>{% endif %}</section>
+1 -1
View File
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions(): def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json() body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
assert body["version"] == "1.3.5" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"] assert body["version"] == "1.3.6" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch): def test_production_refuses_the_dev_token(monkeypatch):
+98
View File
@@ -0,0 +1,98 @@
"""KB search bug fix: the search must be GLOBAL server-side (filter -> count -> order -> paginate), lenient for humans (distinct from the strict
fiscal `kb.alias_match`), and find a product regardless of which page it would fall on. Never touches the fiscal auto-fill rule."""
from sqlalchemy import select
from central.app.enrichment import kb
from central.app.enrichment.observability import kb_search, kb_search_ids
from central.tests.test_control_panel_pages import _panel_client
from central.tests.test_product_enrichment_resolver import env # noqa: F401
from central.tests.test_rtc_security import _become, _post
def seed_product(session, gtin, aliases, ncm=None, client="A"):
p = kb.upsert_product(session, gtin, aliases[0])
for a in aliases[1:]:
kb.add_alias(session, p, a)
if ncm:
kb.add_evidence(session, p, "ncm", ncm, "x", family=kb.CLIENT_FAMILY, client_label=client, lineage={"docs": 20, "years": {"2024": 20}})
session.commit()
return p
def gtin(body12):
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body12)))
return "0" + body12 + str((10 - total % 10) % 10)
def test_search_is_global_not_limited_to_a_rendered_page(env):
target = seed_product(env, gtin("789100050001"), ["COCA-COLA ORIGINAL LATA 350 ML"], ncm="22021000")
for i in range(30): # unrelated noise: if search were page-limited, the target could fall off the visible slice
seed_product(env, gtin(f"78920005{i:04d}"), [f"PRODUTO IRRELEVANTE {i} CAIXA 1 UN"])
ids = kb_search_ids(env, "COCA COLA LATA 350") # missing the unit "ML": would fail the strict fiscal alias_match
assert target.id in ids
def test_search_finds_product_on_first_middle_and_last_page(env):
target = seed_product(env, gtin("789100050002"), ["COCA-COLA ORIGINAL LATA 350 ML"])
for i in range(25):
seed_product(env, gtin(f"78920006{i:04d}"), [f"COCA-COLA ORIGINAL LATA 350 ML VARIANTE {i}"])
for page in (1, 2, 3):
res = kb_search(env, "COCA COLA LATA 350", page=page, page_size=10)
assert res["total"] == 26 and res["pages"] == 3
found_on_last_page = kb_search(env, "COCA COLA LATA 350", page=3, page_size=10)
assert len(found_on_last_page["results"]) >= 1
def test_search_by_gtin_exact(env):
key = gtin("789100050003")
seed_product(env, key, ["COCA-COLA ORIGINAL LATA 350 ML"])
body = key[1:]
assert kb_search_ids(env, body) and kb_search_ids(env, body)[0] == kb_search_ids(env, key)[0]
assert kb_search_ids(env, "0000000000000") == []
def test_search_by_name_with_and_without_hyphen_and_accents(env):
p = seed_product(env, gtin("789100050004"), ["ACAI ORIGINAL POLPA 100 G"])
for query in ("ACAI POLPA 100", "AÇAÍ POLPA 100", "acai polpa 100"):
assert p.id in kb_search_ids(env, query), query
def test_search_zero_results_for_unknown_product(env):
seed_product(env, gtin("789100050005"), ["FEIJAO CARIOCA 1 KG"])
assert kb_search_ids(env, "PRODUTO QUE NAO EXISTE NA BASE") == []
def test_search_before_pagination_semantics(env):
"""Filter runs over the WHOLE table, THEN the result is paginated - not the other way round."""
matches = [seed_product(env, gtin(f"78910006{i:04d}"), [f"SABONETE LIQUIDO ERVA DOCE {i} 200 ML"]) for i in range(15)]
for i in range(40):
seed_product(env, gtin(f"78910007{i:04d}"), [f"OUTRO PRODUTO {i} QUALQUER"])
res = kb_search(env, "SABONETE LIQUIDO ERVA DOCE", page=2, page_size=10)
assert res["total"] == 15 and res["page"] == 2 and len(res["results"]) == 5
found_ids = {r["gtin_key"] for r in kb_search(env, "SABONETE LIQUIDO ERVA DOCE", page=1, page_size=10)["results"]} | {r["gtin_key"] for r in res["results"]}
assert found_ids == {p.gtin for p in matches}
def test_conflict_candidates_all_visible_and_none_auto_confirmed(env):
seed_product(env, gtin("789100050006"), ["COCA COLA LATA 350 ML"], ncm="22030000", client="RINCAO")
p2 = seed_product(env, gtin("789100050006"), ["REFRIGERANTE COCA COLA LATA 350 ML"], ncm="22021000", client="UNIAO")
# both aliases resolve to the SAME kb product (same GTIN key), so seeding into it twice just adds evidence
ids = kb_search_ids(env, "COCA COLA LATA 350")
assert p2.id in ids
def test_admin_attest_button_authorization_end_to_end():
client, agent_id, _ = _panel_client()
with client._session_factory() as s:
from central.tests.test_enrichment_history import staging
from central.tests.test_enrichment_history import run as seed_history
from central.tests.test_product_enrichment_resolver import seed as seed_ncm_dataset
seed_ncm_dataset(s)
seed_history(s, [staging("RINCAO", "7891000500071", "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"),
staging("UNIAO", "7891000500071", "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
_become(client, "PADRAO")
page = client.get("/?kb_q=COCA COLA LATA 350").text
assert "Confirmar este NCM" not in page # button hidden for non-admin
_become(client, "ADMIN")
page = client.get("/?kb_q=COCA COLA LATA 350").text
assert "Confirmar este NCM" in page and "22021000" in page and "19059090" in page
+1 -1
View File
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.5 AGENT_VERSION=1.3.6
+1 -1
View File
@@ -164,7 +164,7 @@ switch ($Command) {
"GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId", "GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId",
"COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource", "COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource",
"FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass", "FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass",
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.3.5' 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.3.6'
) )
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8 Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
} }
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.5 AGENT_VERSION=1.3.6
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.5 AGENT_VERSION=1.3.6
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.5 AGENT_VERSION=1.3.6
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0 FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5 POLL_INTERVAL_SECONDS=5
AGENT_VERSION=1.3.5 AGENT_VERSION=1.3.6
+1 -1
View File
@@ -1,5 +1,5 @@
# GTI Fiscal Hub — production environment (copy to .env, never commit .env) # GTI Fiscal Hub — production environment (copy to .env, never commit .env)
GTI_FISCAL_VERSION=1.3.5 GTI_FISCAL_VERSION=1.3.6
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24 # REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
+1 -1
View File
@@ -36,7 +36,7 @@ services:
build: build:
context: ../.. context: ../..
dockerfile: central/Dockerfile dockerfile: central/Dockerfile
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.3.5} image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.3.6}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
+14
View File
@@ -0,0 +1,14 @@
{
"UPGRADE_SHA_MATCHES_PUBLISHED": true,
"VERSION_1_3_5": true,
"IDENTITY_PRESERVED": true,
"SERVICE_RUNNING": true,
"NAME_ONLY_WRITE_AFTER_UPGRADE": true,
"hub_row": "ENRIQUECIDO|",
"audit": "CLASSIFICACAO_FISCAL:00000000>22021000:APPLIED",
"REBOOT_SAME_IDENTITY": true,
"NO_MANUAL_ENV_EDIT": true,
"HEARTBEAT_AFTER_REBOOT": 3,
"NO_DUPLICATE_AGENT": true,
"AFTER_REBOOT_NAME_ONLY_WRITE": true
}
+4 -4
View File
@@ -1,14 +1,14 @@
{ {
"artifact": "RELEASE_MANIFEST", "artifact": "RELEASE_MANIFEST",
"GTI_FISCAL_VERSION": "1.3.5", "GTI_FISCAL_VERSION": "1.3.6",
"generated_at": "2026-09-25T15:43:40+00:00", "generated_at": "2026-09-25T15:43:40+00:00",
"hub": { "hub": {
"version": "1.3.5", "version": "1.3.6",
"api_compat": "1.0", "api_compat": "1.0",
"engine_version": "0.3.0", "engine_version": "0.3.0",
"decision_version": "mission-04c-ipi-ex-split" "decision_version": "mission-04c-ipi-ex-split"
}, },
"agent_version": "1.3.5", "agent_version": "1.3.6",
"fiscal_engine": { "fiscal_engine": {
"ENGINE_VERSION": "0.3.0", "ENGINE_VERSION": "0.3.0",
"DECISION_VERSION": "mission-04c-ipi-ex-split", "DECISION_VERSION": "mission-04c-ipi-ex-split",
@@ -89,5 +89,5 @@
"record_count": 11103 "record_count": 11103
} }
], ],
"note": "1.3.5: Hub + Agent (re-evaluation polling, stale-write guard). Placeholder semantics arrived in 1.3.4." "note": "1.3.6: Hub only (KB global search fix: server-side, lenient, paginated). No Agent change; the published installer remains GTIFiscalAgent-Setup-1.3.5.exe."
} }
+1 -1
View File
@@ -1 +1 @@
1.3.5 1.3.6
+2 -2
View File
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
def test_versions_are_coherent_across_hub_agent_and_packages(): def test_versions_are_coherent_across_hub_agent_and_packages():
from central.app.version import GTI_FISCAL_VERSION from central.app.version import GTI_FISCAL_VERSION
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip() v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
assert v == GTI_FISCAL_VERSION == "1.3.5" assert v == GTI_FISCAL_VERSION == "1.3.6"
pep = v.replace("-rc", "rc") pep = v.replace("-rc", "rc")
for pp in ("central/pyproject.toml", "agent/pyproject.toml"): for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8") assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
assert '"AGENT_VERSION", "1.3.5"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8") assert '"AGENT_VERSION", "1.3.6"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
manifest = ROOT / "release" / "RELEASE_MANIFEST.json" manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
if manifest.exists(): if manifest.exists():
m = json.loads(manifest.read_text(encoding="utf-8")) m = json.loads(manifest.read_text(encoding="utf-8"))