From a73a986bbe8b8d1a83b5d1d6c36a58ba518b2cde Mon Sep 17 00:00:00 2001 From: oskar Date: Wed, 22 Jul 2026 16:39:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(kb-query):=20add=20search=20frontend=20(mo?= =?UTF-8?q?dule=205=20phase=204,=20plan=20=C2=A77,=20Krok=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krok 4 of the phase-4 plan done ahead of the local-embed-fallback step (Krok 2, deliberately deferred -- embed stays a plain SOLARIA call, per task instruction): one FastAPI process now serves both the /search API and the UI, no separate frontend build (plan §2 decision 4). - GET / renders a Jinja2 shell; app/static/app.js (vanilla, no build) and style.css are the whole client. Query -> /search, results grouped by envelope_id client-side (chunks sorted by dist,
fragments). - Colour thresholds per plan §7: dist<0.45 green, 0.45-0.55 yellow (still shown with a warning), >0.55 never rendered as an individual result; if a query ends up with nothing renderable, one "Brak odpowiedzi w KB" message replaces the list, carrying the best observed dist. - Paperless hits link out; gmail hits get a "kopiuj Message-ID" button (there's nothing to link to yet, plan §2 decision 3) plus header metadata. Cascade/flat toggle defaults to cascade. Footer shows sol_status, refreshed from /healthz on load and after each search. - /search gained additive summary/summary_tags fields (document_summary, haiku track) so the UI can show a document summary as each result group's header -- non-breaking, existing response shape untouched. - Tests: app/db.py + app/search.py unit tests (mocked DB/HTTP, no live deps) cover the new summary join; tests/test_frontend.py drives GET / and /static/* via TestClient without running the DB-requiring lifespan; tests/frontend/app.test.js (Node's built-in test runner, no framework) covers query-URL encoding, threshold colouring, and envelope grouping. - Verified live: docker build + container against kb-postgres@PIHA over LAN and Ollama@SOLARIA over Tailscale -- GET / (HTML), /static/app.js, /healthz, and /search (cascade + flat) all round-tripped correctly, including real summary/summary_tags data. Co-Authored-By: Claude Sonnet 5 --- services/kb-query/README.md | 59 ++++- services/kb-query/app/db.py | 19 ++ services/kb-query/app/main.py | 18 +- services/kb-query/app/search.py | 16 +- services/kb-query/app/static/app.js | 234 +++++++++++++++++++ services/kb-query/app/static/style.css | 157 +++++++++++++ services/kb-query/app/templates/index.html | 43 ++++ services/kb-query/requirements.txt | 2 + services/kb-query/service.yaml | 2 +- services/kb-query/tests/frontend/app.test.js | 49 ++++ services/kb-query/tests/test_frontend.py | 48 ++++ services/kb-query/tests/test_search.py | 45 +++- 12 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 services/kb-query/app/static/app.js create mode 100644 services/kb-query/app/static/style.css create mode 100644 services/kb-query/app/templates/index.html create mode 100644 services/kb-query/tests/frontend/app.test.js create mode 100644 services/kb-query/tests/test_frontend.py diff --git a/services/kb-query/README.md b/services/kb-query/README.md index 44aa6a0..04fb7d1 100644 --- a/services/kb-query/README.md +++ b/services/kb-query/README.md @@ -10,26 +10,59 @@ that's phase 5. | Endpoint | Method | Purpose | |---|---|---| +| `/` | GET | Search UI (Jinja2 shell + `/static/app.js`, no login yet — plan §8 OIDC is a later step) | +| `/static/*` | GET | UI assets (`app.js`, `style.css`) | | `/healthz` | GET | `{"status": "ok", "sol_status": "up"\|"down"}` — `sol_status` is a live probe of Ollama@SOLARIA, no auth required (monitoring must reach it) | | `/search?q=&mode=cascade\|flat` | GET | `query_text -> embed -> cascade_query/flat_query -> results`, `mode` defaults to `cascade` | -`/search` response shape (module 5 phase 4 plan §4): +`/search` response shape (module 5 phase 4 plan §4, `summary`/`summary_tags` +added in Krok 4 for the UI's per-envelope result header — additive, does not +change any field the plan §4 shape already defined): ```json { "query": "...", "mode": "cascade", "sol_status": "up", "results": [ {"envelope_id": "paperless:119", "source": "paperless", "dist": 0.34, - "chunk_index": 2, "text": "...", "link": "https://paper.kapala.org/documents/119/details"}, + "chunk_index": 2, "text": "...", "link": "https://paper.kapala.org/documents/119/details", + "summary": "...", "summary_tags": ["..."]}, {"envelope_id": "", "source": "gmail", "dist": 0.44, "chunk_index": 0, "text": "...", "subject": "...", "from": "...", "date": "...", - "link": null, "mail_ui_url": null} + "link": null, "mail_ui_url": null, "summary": null, "summary_tags": []} ] } ``` -`dist` is never filtered server-side — the 0.45/0.55 colour thresholds are a -frontend concern (a later step), not an API contract. +`dist` is never filtered server-side — the 0.45/0.55 colour thresholds (below) +are a frontend concern, not an API contract. `summary`/`summary_tags` come +from `document_summary` for `SUMMARY_MODEL`; `null`/`[]` when the envelope +has no summary yet. + +## Frontend (Krok 4, plan §7) + +One page, served from this same FastAPI process — no separate frontend +container, no node build step (plan §2 decision 4): `app/templates/index.html` +(Jinja2 shell) + `app/static/app.js` (vanilla JS, `fetch()` to `/search`) + +`app/static/style.css`. Wszystko po polsku. + +- Pole zapytania + submit (Enter lub przycisk), przełącznik trybu + kaskada/flat (domyślnie kaskada — checkbox "tryb flat (debug)"). +- Wyniki grupowane po `envelope_id` (dokument): nagłówek trafienia to + streszczenie dokumentu (`summary`, tor haiku) gdy dostępne, w przeciwnym + razie `envelope_id`; chunki są rozwijanymi fragmentami (`
`) pod + nagłówkiem, posortowane po `dist`. +- Kolorowanie progów (fazy 3, zweryfikowane bramką): `dist < 0.45` zielony, + `0.45–0.55` żółty (nadal renderowany, z wizualnym ostrzeżeniem), `> 0.55` + nigdy nie renderowany jako pojedynczy wynik. Jeśli po tym filtrze żadna + grupa nie zostaje nic do pokazania (wszystkie trafienia > 0.55, albo brak + trafień w ogóle), całość zastępuje komunikat "Brak odpowiedzi w KB dla + tego zapytania" z najlepszym (najniższym) zaobserwowanym `dist` w nawiasie. +- Źródło: Paperless → link "Otwórz w Paperless" (`link`); Gmail → metadane + (`subject`/`from`/`date`) + przycisk "Kopiuj Message-ID" (`envelope_id` + **jest** Message-ID, plan §2 decyzja 3) — nie ma dokąd linkować, więc + kopiowalny identyfikator zamiast martwego linku. +- Stopka pokazuje `sol_status` dyskretnie (odświeżane z `/healthz` przy + starcie strony i po każdym wyszukiwaniu). ## Embed path (current step) @@ -65,21 +98,27 @@ that *wrote* the summary, e.g. `claude-haiku-4-5`, not the embedder). -f hosts/piha/runtime/kb-query/docker-compose.override.yml up -d --build ``` 4. Verify: `services/kb-query/healthcheck.sh`, then from PIHA: - `curl "http://192.168.31.5:8230/search?q=test"`. + `curl "http://192.168.31.5:8230/search?q=test"` and open + `http://192.168.31.5:8230/` in a browser. ## Tests ``` pip install -e packages/kb-retrieval/ -cd services/kb-query && pytest +cd services/kb-query && pip install -r requirements.txt pytest pytest-asyncio && pytest ``` Unit tests mock the DB connection and Ollama HTTP session (no live DB/Ollama -required) — same style as `packages/kb-retrieval/tests/`. +required) — same style as `packages/kb-retrieval/tests/`. `tests/test_frontend.py` +drives `GET /`/`/static/*` through FastAPI's `TestClient` without entering it +as a context manager, so the DB-requiring `lifespan` never runs. + +Frontend JS has its own pure-function tests (query-URL encoding, threshold +colouring, envelope grouping), run without a browser via Node's built-in +test runner: `node --test services/kb-query/tests/frontend/`. ## Out of scope for this step - Local-PIHA embed fallback / circuit breaker (plan §2 decision 2, §5). - npm@PIHA vhost, OIDC login, DNS (plan §8) — `kb.kapala.org` is not wired up - yet; reach the API directly over LAN/Tailscale for now. -- Frontend (plan §7) — `/search` returns bare JSON. + yet; reach the API/UI directly over LAN/Tailscale for now, no auth. diff --git a/services/kb-query/app/db.py b/services/kb-query/app/db.py index fb011d3..864fbfb 100644 --- a/services/kb-query/app/db.py +++ b/services/kb-query/app/db.py @@ -34,3 +34,22 @@ async def fetch_envelopes(conn: asyncpg.Connection, envelope_ids: list[str]) -> r["id"]: {"source": r["source"], "entities": _decode_jsonb(r["entities"]) or []} for r in rows } + + +async def fetch_summaries( + conn: asyncpg.Connection, envelope_ids: list[str], summary_model: str +) -> dict[str, dict]: + """Batch-fetch `document_summary.summary`/`tags` (the haiku compilation track) for the + envelopes a search hit, keyed by envelope id -- frontend krok (plan §7): document summary + as the per-envelope result header, chunks as its expandable fragments.""" + if not envelope_ids: + return {} + rows = await conn.fetch( + "SELECT envelope_id, summary, tags FROM document_summary " + "WHERE envelope_id = ANY($1::text[]) AND model = $2", + envelope_ids, summary_model, + ) + return { + r["envelope_id"]: {"summary": r["summary"], "tags": _decode_jsonb(r["tags"]) or []} + for r in rows + } diff --git a/services/kb-query/app/main.py b/services/kb-query/app/main.py index ee44ffa..4520ea3 100644 --- a/services/kb-query/app/main.py +++ b/services/kb-query/app/main.py @@ -7,14 +7,22 @@ Embed path is deliberately simple for this step: calls Ollama on SOLARIA directl cache/circuit-breaker/local-PIHA-fallback (plan §2 decision 2, §5) -- that state machine is a later, separate step. A failed embed call (SOLARIA unreachable) surfaces as 503 to the caller rather than a bare 500. + +`GET /` (Krok 4, plan §7) serves the search UI from this same FastAPI process -- one image, one +container (plan §2 decision 4): a Jinja2 shell + a static vanilla-JS file, no node build step. +`/` and `/static/*` need no DB/Ollama, so they stay reachable even while `/search` is 503ing. """ from __future__ import annotations import os +import pathlib from contextlib import asynccontextmanager import aiohttp -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates from kb_retrieval.embed import check_ollama_health @@ -22,6 +30,7 @@ from app.db import create_pool from app.search import run_search from app.startup import validate_embed_model +BASE_DIR = pathlib.Path(__file__).resolve().parent KB_DSN = os.environ.get("KB_DSN") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://solaria:11434") EMBED_MODEL = os.environ.get("EMBED_MODEL", "bge-m3") @@ -50,6 +59,13 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") +templates = Jinja2Templates(directory=BASE_DIR / "templates") + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse(request, "index.html") @app.get("/healthz") diff --git a/services/kb-query/app/search.py b/services/kb-query/app/search.py index ff93162..19fdd96 100644 --- a/services/kb-query/app/search.py +++ b/services/kb-query/app/search.py @@ -5,6 +5,11 @@ from FastAPI so it can be unit-tested with fake `conn`/`session` objects, the sa Response shape (plan §4 exactly): `{"query", "mode", "sol_status", "results": [...]}`, each result carrying `dist` un-filtered -- the 0.45/0.55 colour thresholds (plan §7) are a frontend concern (Krok 4, out of this step's scope), never applied server-side. + +`summary`/`summary_tags` (document_summary, haiku track) are an additive field added in Krok 4 +for the frontend's per-envelope result header (plan §7) -- `None`/`[]` when the envelope has no +summary for `summary_model` yet. Purely additive: does not change any field already covered by +the phase-4 gate's HTTP-equivalence check (plan §9). """ from __future__ import annotations @@ -13,7 +18,7 @@ import asyncpg from kb_retrieval.retrieval import cascade_query, flat_query -from app.db import fetch_envelopes +from app.db import fetch_envelopes, fetch_summaries from app.links import build_result @@ -37,8 +42,15 @@ async def run_search( chunks = retrieval["chunks"] envelope_ids = sorted({c["envelope_id"] for c in chunks}) envelopes = await fetch_envelopes(conn, envelope_ids) + summaries = await fetch_summaries(conn, envelope_ids, summary_model) - results = [build_result(chunk, envelopes.get(chunk["envelope_id"])) for chunk in chunks] + results = [] + for chunk in chunks: + result = build_result(chunk, envelopes.get(chunk["envelope_id"])) + summary = summaries.get(chunk["envelope_id"]) + result["summary"] = summary["summary"] if summary else None + result["summary_tags"] = summary["tags"] if summary else [] + results.append(result) return { "query": query_text, diff --git a/services/kb-query/app/static/app.js b/services/kb-query/app/static/app.js new file mode 100644 index 0000000..483d3d1 --- /dev/null +++ b/services/kb-query/app/static/app.js @@ -0,0 +1,234 @@ +// kb-query frontend -- module 5 phase 4 (docs/kb/modules/05-faza4-plan.md §7). Vanilla JS, no +// build step (plan §2 decision 4): fetch()s /search, renders results grouped by envelope_id. +// +// Threshold rule (plan §7, task spec): dist < 0.45 green, 0.45-0.55 yellow (still rendered with +// a visual warning), > 0.55 never rendered as an individual result. If a query ends up with zero +// renderable chunks (every hit > 0.55, or no hits at all), the whole group list is replaced by +// one message instead -- "brak odpowiedzi w KB", carrying the best (lowest) dist seen so the +// user can tell a near-miss from nothing at all. +(function () { + 'use strict'; + + const GREEN_MAX = 0.45; + const YELLOW_MAX = 0.55; + + function buildSearchUrl(query, mode) { + const params = new URLSearchParams({ q: query, mode: mode }); + return '/search?' + params.toString(); + } + + function distClass(dist) { + if (dist < GREEN_MAX) return 'dist-green'; + if (dist <= YELLOW_MAX) return 'dist-yellow'; + return 'dist-red'; + } + + // Flat /search results -> per-envelope groups, chunks sorted best-first within each group. + // Insertion order follows first appearance, which already tracks best-group-first since the + // API's own result list is globally dist-sorted (kb_retrieval.cascade_retrieve/flat_retrieve). + function groupResults(results) { + const groups = []; + const byId = new Map(); + for (const r of results) { + let group = byId.get(r.envelope_id); + if (!group) { + group = { + envelopeId: r.envelope_id, + source: r.source, + summary: r.summary, + summaryTags: r.summary_tags || [], + link: r.link, + mailUiUrl: r.mail_ui_url, + subject: r.subject, + from: r.from, + date: r.date, + chunks: [], + }; + byId.set(r.envelope_id, group); + groups.push(group); + } + group.chunks.push(r); + } + for (const group of groups) { + group.chunks.sort((a, b) => a.dist - b.dist); + } + return groups; + } + + function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + // Fallback for plain-http LAN access (no secure context -> Clipboard API unavailable). + const el = document.createElement('textarea'); + el.value = text; + el.style.position = 'fixed'; + el.style.opacity = '0'; + document.body.appendChild(el); + el.select(); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(el); + } + return Promise.resolve(); + } + + function renderSolStatus(el, status) { + el.textContent = 'SOLARIA: ' + (status === 'up' ? 'online' : 'offline (fallback embed)'); + el.className = 'sol-status ' + (status === 'up' ? 'sol-up' : 'sol-down'); + } + + function renderChunk(chunk) { + const details = document.createElement('details'); + details.className = 'chunk ' + distClass(chunk.dist); + const summaryEl = document.createElement('summary'); + summaryEl.textContent = 'Fragment ' + chunk.chunk_index + ' (dist ' + chunk.dist.toFixed(4) + ')'; + const body = document.createElement('pre'); + body.className = 'chunk-text'; + body.textContent = chunk.text; + details.appendChild(summaryEl); + details.appendChild(body); + return details; + } + + function renderGroup(group) { + const article = document.createElement('article'); + article.className = 'result-group'; + + const header = document.createElement('h3'); + header.textContent = group.summary || group.envelopeId; + article.appendChild(header); + + if (group.summaryTags.length) { + const tags = document.createElement('div'); + tags.className = 'tags'; + tags.textContent = group.summaryTags.join(', '); + article.appendChild(tags); + } + + const meta = document.createElement('div'); + meta.className = 'source-meta'; + if (group.source === 'paperless' && group.link) { + const a = document.createElement('a'); + a.href = group.link; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Otwórz w Paperless'; + meta.appendChild(a); + } else if (group.source === 'gmail') { + const info = document.createElement('span'); + info.textContent = [group.subject, group.from, group.date].filter(Boolean).join(' — '); + meta.appendChild(info); + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'Kopiuj Message-ID'; + btn.addEventListener('click', function () { + copyToClipboard(group.envelopeId).then(function () { + btn.textContent = 'Skopiowano!'; + setTimeout(function () { + btn.textContent = 'Kopiuj Message-ID'; + }, 1500); + }); + }); + meta.appendChild(btn); + } + if (meta.childNodes.length) { + article.appendChild(meta); + } + + for (const chunk of group.chunks) { + article.appendChild(renderChunk(chunk)); + } + return article; + } + + function render(container, data) { + container.innerHTML = ''; + + let bestDist = null; + for (const r of data.results) { + if (bestDist === null || r.dist < bestDist) bestDist = r.dist; + } + + const groups = groupResults(data.results); + let anyVisible = false; + for (const group of groups) { + group.chunks = group.chunks.filter(function (c) { + return c.dist <= YELLOW_MAX; + }); + if (group.chunks.length) anyVisible = true; + } + + if (!anyVisible) { + const msg = document.createElement('p'); + msg.className = 'no-answer'; + msg.textContent = bestDist === null + ? 'Brak odpowiedzi w KB dla tego zapytania.' + : 'Brak odpowiedzi w KB dla tego zapytania (najlepszy dist: ' + bestDist.toFixed(4) + ').'; + container.appendChild(msg); + return; + } + + for (const group of groups) { + if (group.chunks.length) { + container.appendChild(renderGroup(group)); + } + } + } + + function init() { + const form = document.getElementById('search-form'); + const queryInput = document.getElementById('query'); + const modeToggle = document.getElementById('mode-flat'); + const results = document.getElementById('results'); + const errorBox = document.getElementById('error'); + const solStatusEl = document.getElementById('sol-status'); + + async function refreshSolStatus() { + try { + const resp = await fetch('/healthz'); + const data = await resp.json(); + renderSolStatus(solStatusEl, data.sol_status); + } catch (err) { + renderSolStatus(solStatusEl, 'down'); + } + } + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + const query = queryInput.value.trim(); + if (!query) return; + const mode = modeToggle.checked ? 'flat' : 'cascade'; + + errorBox.hidden = true; + results.innerHTML = '

Szukam…

'; + + try { + const resp = await fetch(buildSearchUrl(query, mode)); + if (!resp.ok) { + const body = await resp.json().catch(function () { + return {}; + }); + throw new Error(body.detail || ('Błąd wyszukiwania (' + resp.status + ')')); + } + const data = await resp.json(); + render(results, data); + renderSolStatus(solStatusEl, data.sol_status); + } catch (err) { + results.innerHTML = ''; + errorBox.textContent = 'Wyszukiwanie chwilowo niedostępne: ' + err.message; + errorBox.hidden = false; + } + }); + + refreshSolStatus(); + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { buildSearchUrl, groupResults, distClass }; + } else { + document.addEventListener('DOMContentLoaded', init); + } +})(); diff --git a/services/kb-query/app/static/style.css b/services/kb-query/app/static/style.css new file mode 100644 index 0000000..3182b20 --- /dev/null +++ b/services/kb-query/app/static/style.css @@ -0,0 +1,157 @@ +:root { + color-scheme: light dark; + --green: #2e7d32; + --yellow: #b58900; + --red: #b3261e; + --border: #8884; + --muted: #767676; +} + +* { + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + max-width: 48rem; + margin: 0 auto; + padding: 1.5rem 1rem 4rem; + line-height: 1.4; +} + +header h1 { + margin-bottom: 0.2rem; +} + +.subtitle { + color: var(--muted); + margin-top: 0; +} + +#search-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-bottom: 1.5rem; +} + +#query { + flex: 1 1 20rem; + padding: 0.5rem 0.7rem; + font-size: 1rem; + border: 1px solid var(--border); + border-radius: 0.3rem; +} + +button[type="submit"] { + padding: 0.5rem 1.1rem; + font-size: 1rem; + border: 1px solid var(--border); + border-radius: 0.3rem; + cursor: pointer; +} + +.mode-toggle { + font-size: 0.85rem; + color: var(--muted); + display: flex; + align-items: center; + gap: 0.3rem; + white-space: nowrap; +} + +.error { + color: var(--red); + border: 1px solid var(--red); + border-radius: 0.3rem; + padding: 0.6rem 0.8rem; + margin-bottom: 1rem; +} + +.loading, +.no-answer { + color: var(--muted); +} + +.result-group { + border: 1px solid var(--border); + border-radius: 0.4rem; + padding: 0.8rem 1rem; + margin-bottom: 1rem; +} + +.result-group h3 { + margin: 0 0 0.3rem; +} + +.tags { + font-size: 0.8rem; + color: var(--muted); + margin-bottom: 0.4rem; +} + +.source-meta { + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 0.6rem; + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +.source-meta button { + font-size: 0.8rem; + padding: 0.15rem 0.5rem; + cursor: pointer; +} + +.chunk { + margin: 0.3rem 0; + border-left: 4px solid var(--border); + padding-left: 0.6rem; +} + +.chunk summary { + cursor: pointer; + font-size: 0.85rem; +} + +.chunk-text { + white-space: pre-wrap; + font-family: inherit; + font-size: 0.9rem; + margin: 0.4rem 0 0.2rem; +} + +.dist-green { + border-left-color: var(--green); +} + +.dist-yellow { + border-left-color: var(--yellow); +} + +.dist-yellow summary { + color: var(--yellow); +} + +footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 0.4rem 1rem; + font-size: 0.75rem; + color: var(--muted); + text-align: right; +} + +.sol-up { + color: var(--green); +} + +.sol-down { + color: var(--red); +} diff --git a/services/kb-query/app/templates/index.html b/services/kb-query/app/templates/index.html new file mode 100644 index 0000000..389a994 --- /dev/null +++ b/services/kb-query/app/templates/index.html @@ -0,0 +1,43 @@ + + + + + + Wyszukiwarka KB + + + +
+

Wyszukiwarka KB

+

Wyszukiwanie w bazie wiedzy (Paperless + Gmail) — nie czat, tylko wyszukiwarka.

+
+ +
+
+ + + +
+ + +
+
+ +
+ SOLARIA: sprawdzanie… +
+ + + + diff --git a/services/kb-query/requirements.txt b/services/kb-query/requirements.txt index 6ae617e..ed27978 100644 --- a/services/kb-query/requirements.txt +++ b/services/kb-query/requirements.txt @@ -2,3 +2,5 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 aiohttp==3.11.11 asyncpg==0.30.0 +jinja2==3.1.5 +httpx==0.28.1 diff --git a/services/kb-query/service.yaml b/services/kb-query/service.yaml index 62236e9..37c7bb4 100644 --- a/services/kb-query/service.yaml +++ b/services/kb-query/service.yaml @@ -1,7 +1,7 @@ service: name: kb-query owner_node: piha - role: kb-search-api # module 5 phase 4: first user-facing HTTP entry point to the KB + role: kb-search-api # module 5 phase 4: first user-facing HTTP entry point to the KB (API + minimal search UI, plan §7) exposure: private # LAN/Tailscale only, npm@PIHA vhost (kb.kapala.org) is a later step dependencies: - kb-postgres diff --git a/services/kb-query/tests/frontend/app.test.js b/services/kb-query/tests/frontend/app.test.js new file mode 100644 index 0000000..0b2b124 --- /dev/null +++ b/services/kb-query/tests/frontend/app.test.js @@ -0,0 +1,49 @@ +// Pure-function tests for app/static/app.js -- module 5 phase 4 §7 DoD: "test that frontend-JS +// correctly encodes the query" (no browser needed; the module guards its DOM-touching init() +// behind a `document`-only branch, so requiring it under plain Node is safe -- see the +// `typeof module` check at the bottom of app.js). +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { buildSearchUrl, groupResults, distClass } = require( + path.join(__dirname, '..', '..', 'app', 'static', 'app.js') +); + +test('buildSearchUrl percent-encodes special characters in the query', () => { + const url = buildSearchUrl('polisa OC & AC?', 'cascade'); + assert.equal(url, '/search?q=polisa+OC+%26+AC%3F&mode=cascade'); +}); + +test('buildSearchUrl encodes Polish diacritics', () => { + const url = buildSearchUrl('faktura zażółć', 'cascade'); + assert.equal(url, '/search?q=faktura+za%C5%BC%C3%B3%C5%82%C4%87&mode=cascade'); +}); + +test('buildSearchUrl carries mode=flat through untouched', () => { + const url = buildSearchUrl('faktura', 'flat'); + assert.equal(url, '/search?q=faktura&mode=flat'); +}); + +test('distClass applies the phase-3 thresholds (green <0.45, yellow 0.45-0.55, red >0.55)', () => { + assert.equal(distClass(0.1), 'dist-green'); + assert.equal(distClass(0.449), 'dist-green'); + assert.equal(distClass(0.45), 'dist-yellow'); + assert.equal(distClass(0.55), 'dist-yellow'); + assert.equal(distClass(0.551), 'dist-red'); +}); + +test('groupResults groups chunks by envelope_id and sorts each group by dist', () => { + const results = [ + { envelope_id: 'paperless:1', source: 'paperless', dist: 0.4, chunk_index: 1, text: 'b', summary: 'S', summary_tags: [], link: 'https://x' }, + { envelope_id: 'paperless:1', source: 'paperless', dist: 0.2, chunk_index: 0, text: 'a', summary: 'S', summary_tags: [], link: 'https://x' }, + { envelope_id: 'gmail:1', source: 'gmail', dist: 0.3, chunk_index: 0, text: 'c', summary: null, summary_tags: [] }, + ]; + const groups = groupResults(results); + assert.equal(groups.length, 2); + assert.equal(groups[0].envelopeId, 'paperless:1'); + assert.deepEqual(groups[0].chunks.map((c) => c.chunk_index), [0, 1]); + assert.equal(groups[1].envelopeId, 'gmail:1'); +}); diff --git a/services/kb-query/tests/test_frontend.py b/services/kb-query/tests/test_frontend.py new file mode 100644 index 0000000..9f39cba --- /dev/null +++ b/services/kb-query/tests/test_frontend.py @@ -0,0 +1,48 @@ +"""Unit tests for the frontend endpoints (GET /, static assets) -- module 5 phase 4 §7. +Uses FastAPI's TestClient without entering it as a context manager, so the app's `lifespan` +(which needs a live kb-postgres DSN, app/main.py:34-49) never runs -- `/` and `/static/*` don't +touch `app.state`, so this is safe and keeps these tests DB-free like the rest of the suite. +`/search`/`/healthz` behavior is covered separately (tests/test_search.py) against the +DB-independent `run_search` core, not through this TestClient.""" +from __future__ import annotations + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +from fastapi.testclient import TestClient # noqa: E402 + +from app.main import app # noqa: E402 + +client = TestClient(app) + + +class TestIndex: + def test_returns_html(self): + response = client.get("/") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + + def test_contains_search_form_and_polish_copy(self): + response = client.get("/") + assert 'id="search-form"' in response.text + assert 'id="query"' in response.text + assert 'id="mode-flat"' in response.text + assert "Wyszukiwarka KB" in response.text + + def test_references_static_assets(self): + response = client.get("/") + assert "/static/app.js" in response.text + assert "/static/style.css" in response.text + + +class TestStaticAssets: + def test_app_js_served(self): + response = client.get("/static/app.js") + assert response.status_code == 200 + assert "buildSearchUrl" in response.text + + def test_style_css_served(self): + response = client.get("/static/style.css") + assert response.status_code == 200 diff --git a/services/kb-query/tests/test_search.py b/services/kb-query/tests/test_search.py index 73c1e66..570deb9 100644 --- a/services/kb-query/tests/test_search.py +++ b/services/kb-query/tests/test_search.py @@ -12,15 +12,25 @@ from app.search import run_search # noqa: E402 class _FakeConn: - """summaries: [(envelope_id, dist), ...]. chunks_by_envelope: envelope_id -> [(chunk_index, - text, dist), ...]. envelopes: envelope_id -> {"source": ..., "entities": [...]}.""" + """summaries: [(envelope_id, dist), ...] -- cascade stage-1 pre-filter. chunks_by_envelope: + envelope_id -> [(chunk_index, text, dist), ...]. envelopes: envelope_id -> {"source": ..., + "entities": [...]}. summary_texts: envelope_id -> {"summary": ..., "tags": [...]} -- the + document_summary row fetched for the result header (app/db.py fetch_summaries).""" - def __init__(self, summaries=None, chunks_by_envelope=None, envelopes=None): + def __init__(self, summaries=None, chunks_by_envelope=None, envelopes=None, summary_texts=None): self._summaries = list(summaries or []) self._chunks_by_envelope = chunks_by_envelope or {} self._envelopes = envelopes or {} + self._summary_texts = summary_texts or {} async def fetch(self, query, *params): + if "FROM document_summary" in query and "= ANY" in query: + (envelope_ids, _model) = params + return [ + {"envelope_id": eid, "summary": s["summary"], "tags": s["tags"]} + for eid, s in self._summary_texts.items() + if eid in envelope_ids + ] if "FROM document_summary" in query: _, _model, limit = params return [{"envelope_id": eid, "dist": dist} for eid, dist in self._summaries[:limit]] @@ -113,6 +123,35 @@ class TestRunSearchHappyPath: assert result["mode"] == "flat" assert len(result["results"]) == 1 + async def test_summary_attached_when_document_summary_row_exists(self): + conn = _FakeConn( + summaries=[("paperless:119", 0.1)], + chunks_by_envelope={"paperless:119": [(2, "hit text", 0.34)]}, + envelopes={"paperless:119": {"source": "paperless", "entities": []}}, + summary_texts={"paperless:119": {"summary": "Polisa OC 2024", "tags": ["ubezpieczenia"]}}, + ) + session = _FakeSession() + result = await run_search( + conn, session, "http://fake-ollama", "polisa PZU", "cascade", "bge-m3", "claude-haiku-4-5" + ) + hit = result["results"][0] + assert hit["summary"] == "Polisa OC 2024" + assert hit["summary_tags"] == ["ubezpieczenia"] + + async def test_summary_defaults_to_none_when_no_document_summary_row(self): + conn = _FakeConn( + summaries=[("paperless:119", 0.1)], + chunks_by_envelope={"paperless:119": [(2, "hit text", 0.34)]}, + envelopes={"paperless:119": {"source": "paperless", "entities": []}}, + ) + session = _FakeSession() + result = await run_search( + conn, session, "http://fake-ollama", "polisa PZU", "cascade", "bge-m3", "claude-haiku-4-5" + ) + hit = result["results"][0] + assert hit["summary"] is None + assert hit["summary_tags"] == [] + async def test_gmail_hit_carries_header_metadata_not_a_link(self): conn = _FakeConn( summaries=[("", 0.1)],