homelab-codex-ws/services/kb-query/app/static/app.js

238 lines
7.8 KiB
JavaScript
Raw Permalink Normal View History

// kb-query frontend -- module 5 phase 4 (kb/phases/kb-m5-faza4.md §7). Vanilla JS, no
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:39:46 +02:00
// 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;
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej) Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki: eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs na PIHA (niecommitowane, artefakt runu). - app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad. - app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz parametru mode w ogole -- dziedziczy default API. Default zdefiniowany w jednym miejscu (serwer), nie zduplikowany w JS. - testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan, fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid (weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak. - docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze SUMMARY_MODEL; default mode nie jest konfigurowalny przez env), kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu). Weryfikacja: pytest services/kb-query -> 46 passed; node --test tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example). Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
// mode omitted (falsy) -> the API's own default answers (hybrid since 2026-08-06,
// kb/phases/kb-m5-faza-mailowa.md DoD (d)). One place defines the default: the server.
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:39:46 +02:00
function buildSearchUrl(query, mode) {
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej) Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki: eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs na PIHA (niecommitowane, artefakt runu). - app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad. - app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz parametru mode w ogole -- dziedziczy default API. Default zdefiniowany w jednym miejscu (serwer), nie zduplikowany w JS. - testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan, fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid (weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak. - docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze SUMMARY_MODEL; default mode nie jest konfigurowalny przez env), kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu). Weryfikacja: pytest services/kb-query -> 46 passed; node --test tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example). Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
const params = new URLSearchParams({ q: query });
if (mode) params.set('mode', mode);
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:39:46 +02:00
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;
feat(kb-query): domyslny mode /search = hybrid (DoD (d) fazy mailowej) Eval na pelnym korpusie 2026-08-06 (187 025 zembedowanych chunkow mailowych w HNSW) dal PASS: kryterium 1 (regresja paperless) bez degradacji zadnego istniejacego hitu we flat ani w hybrid, mailowe hit@3 5/5. Koszt hybrydy to jedno dodatkowe zapytanie SQL na wyszukiwanie. Surowe wyniki: eval-http-2026-08-06.json / eval-direct-2026-08-06.json w ~/kb/mail/ingest-logs na PIHA (niecommitowane, artefakt runu). - app/main.py: Query("cascade") -> Query("hybrid"); pattern bez zmian, wiec jawne ?mode=cascade i ?mode=flat dzialaja dokladnie jak dotad. - app/static/app.js: przy odznaczonym "tryb flat (debug)" UI nie wysyla juz parametru mode w ogole -- dziedziczy default API. Default zdefiniowany w jednym miejscu (serwer), nie zduplikowany w JS. - testy: nowa klasa TestSearchEndpointModeDefault (TestClient bez lifespan, fake pool/router) sprawdza kontrakt HTTP -- brak mode => tor hybrid (weryfikowany po obecnosci koperty gmail osiagalnej wylacznie galezia hybrid, nie po samej etykiecie), jawne mode=flat / mode=cascade => stare tory, nieznany mode => 422. Frontend: buildSearchUrl pomija mode gdy brak. - docs: kb/services/kb-query.md (tabela trybow + endpoint + przyklad odpowiedzi + opis przelacznika w UI), env.example/service.yaml (komentarze SUMMARY_MODEL; default mode nie jest konfigurowalny przez env), kb/phases/kb-m5-faza-mailowa.md (DoD (d) SPELNIONE 2026-08-06 + wzmianki w Kroku 3, Wyniku bramki, decyzjach Etapu B i tabeli planu). Weryfikacja: pytest services/kb-query -> 46 passed; node --test tests/frontend/app.test.js -> 6/6; docker build OK + smoke run (uvicorn startuje, bez KB_DSN swiadomie konczy sie RuntimeError z env.example). Deploy NIE wykonany -- operator wdraza z mastera na PIHA po mergu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:02 +02:00
const mode = modeToggle.checked ? 'flat' : null;
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:39:46 +02:00
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);
}
})();