homelab-codex-ws/services/kb-query/app/static/app.js
oskar a73a986bbe 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>
2026-07-22 16:49:36 +02:00

235 lines
7.6 KiB
JavaScript

// 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);
}
})();