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:
parent
970b8cc023
commit
a73a986bbe
|
|
@ -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.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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
234
services/kb-query/app/static/app.js
Normal file
234
services/kb-query/app/static/app.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
157
services/kb-query/app/static/style.css
Normal file
157
services/kb-query/app/static/style.css
Normal 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);
|
||||
}
|
||||
43
services/kb-query/app/templates/index.html
Normal file
43
services/kb-query/app/templates/index.html
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
services/kb-query/tests/frontend/app.test.js
Normal file
49
services/kb-query/tests/frontend/app.test.js
Normal 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');
|
||||
});
|
||||
48
services/kb-query/tests/test_frontend.py
Normal file
48
services/kb-query/tests/test_frontend.py
Normal 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
|
||||
|
|
@ -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)],
|
||||
|
|
|
|||
Loading…
Reference in a new issue