feat(kb-query): add search frontend (module 5 phase 4, plan §7, Krok 4)

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, <details> 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 <noreply@anthropic.com>
This commit is contained in:
oskar 2026-07-22 16:39:46 +02:00
parent 970b8cc023
commit a73a986bbe
12 changed files with 675 additions and 17 deletions

View file

@ -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=<text>&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": "<Message-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 (`<details>`) pod
nagłówkiem, posortowane po `dist`.
- Kolorowanie progów (fazy 3, zweryfikowane bramką): `dist < 0.45` zielony,
`0.450.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.

View file

@ -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
}

View file

@ -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")

View file

@ -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,

View file

@ -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 = '<p class="loading">Szukam…</p>';
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);
}
})();

View file

@ -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);
}

View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wyszukiwarka KB</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>Wyszukiwarka KB</h1>
<p class="subtitle">Wyszukiwanie w bazie wiedzy (Paperless + Gmail) — nie czat, tylko wyszukiwarka.</p>
</header>
<main>
<form id="search-form">
<input
type="text"
id="query"
name="q"
placeholder="Wpisz zapytanie…"
autocomplete="off"
autofocus
required
>
<button type="submit">Szukaj</button>
<label class="mode-toggle" title="Tryb debugowy: pomija filtr po streszczeniach dokumentów">
<input type="checkbox" id="mode-flat">
tryb flat (debug)
</label>
</form>
<p id="error" class="error" hidden></p>
<div id="results"></div>
</main>
<footer>
<span id="sol-status" class="sol-status">SOLARIA: sprawdzanie…</span>
</footer>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -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

View file

@ -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

View file

@ -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');
});

View file

@ -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

View file

@ -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=[("<msgid@example.com>", 0.1)],