From b1b6692382c13df71e89f063669758ad86003b2c Mon Sep 17 00:00:00 2001 From: oskar Date: Thu, 6 Aug 2026 13:30:02 +0200 Subject: [PATCH] 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) --- kb/phases/kb-m5-faza-mailowa.md | 24 +++++- kb/services/kb-query.md | 29 +++++-- services/kb-query/app/main.py | 12 ++- services/kb-query/app/static/app.js | 7 +- services/kb-query/env.example | 4 +- services/kb-query/service.yaml | 2 +- services/kb-query/tests/frontend/app.test.js | 7 ++ services/kb-query/tests/test_search.py | 83 ++++++++++++++++++++ 8 files changed, 151 insertions(+), 17 deletions(-) diff --git a/kb/phases/kb-m5-faza-mailowa.md b/kb/phases/kb-m5-faza-mailowa.md index e63aceb..bb1059b 100644 --- a/kb/phases/kb-m5-faza-mailowa.md +++ b/kb/phases/kb-m5-faza-mailowa.md @@ -463,7 +463,8 @@ i idempotencji (drugi przebieg = zero insertów). Definition of Done z CLAUDE.md `hybrid_query` analogicznie do `cascade_query` (jeden embed zapytania). - `kb-query`: `mode` pattern `^(cascade|flat|hybrid)$`; **domyślny `mode` przełączany na `hybrid` dopiero po PASS bramki (§8)** — do tego czasu - hybrid dostępny jawnie. Wyniki gmail w UI już obsłużone (faza 4: subject/from + hybrid dostępny jawnie. *(Wykonane 2026-08-06: default = `hybrid`, patrz + DoD (d) w §12.)* Wyniki gmail w UI już obsłużone (faza 4: subject/from z headers + „Kopiuj Message-ID"). - Testy jednostkowe na mockach (merge, pusta gałąź summary, pusta gałąź mail). @@ -578,7 +579,8 @@ płonił bramki co uruchomienie). **`kb-query` domyślny `mode`**: przełączenie na `hybrid` jako follow-up (poza zakresem tego zamknięcia bramki — `kb-query`'s `mode` param zmiana to osobna, -mała zmiana w serwisie, nie w `packages/kb-retrieval`). +mała zmiana w serwisie, nie w `packages/kb-retrieval`). **Wykonane 2026-08-06** +po regresji na pełnym korpusie (Etap B) — szczegóły przy DoD (d) w §12. ## 9. Krok 6 — Etap B: pełne archiwum @@ -620,7 +622,7 @@ zatrzymaniu) wykazał dwie rzeczy do rozstrzygnięcia. Decyzje: wystarczają; ewentualna kalibracja heurystyki na dekadzie 2010–2015 po fakcie, na już zapisanych flagach. 4. Przełączenie domyślnego `mode` kb-query na `hybrid` (DoD (d)) — **poza zakresem - Etapu B**, osobny task po PASS regresji. + Etapu B**, osobny task po PASS regresji. *(Wykonany 2026-08-06 — DoD (d) w §12.)* ### Hardening toru embed przed Etapem B (2026-08-05) @@ -728,7 +730,7 @@ Zakotwiczone w kb-00 jako etapy 3–4 (`jobs/fastmail-poller`, | 0 | Chunker → `packages/kb-mail` | — | 0,5 sesji | **WYKONANE** | `348ce10`; `packages/kb-mail/src/kb_mail/chunking.py` + `tests/test_chunking.py` | | 1 | `embed_batch` w kb-retrieval | — | 0,5 sesji | **WYKONANE** | `51998fd`; `kb_retrieval/embed.py:61` (`embed_batch`) + `tests/test_embed.py` | | 2 | Job `mail-body-ingest` | 0, 1 | 2 sesje | **WYKONANE** | `ad0ef40` (job), `a95524c` (README), `fc5c698` (fix html_to_text); `jobs/mail-body-ingest/` + `tests/test_ingest.py` | -| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | **WYKONANE** | `a640cf1`; `kb_retrieval/retrieval.py:112` (`hybrid_retrieve`), `:195` (`hybrid_query`), `kb-query/app/main.py:119` (`mode` pattern). Uwaga: domyślny `mode` to nadal `cascade` — przełączenie to follow-up z §8, nie część Kroku 3 | +| 3 | Tryb hybrid (kb-retrieval + kb-query) | — (równolegle z 2) | 1 sesja | **WYKONANE** | `a640cf1`; `kb_retrieval/retrieval.py:112` (`hybrid_retrieve`), `:195` (`hybrid_query`), `kb-query/app/main.py:119` (`mode` pattern). Domyślny `mode` przełączony na `hybrid` 2026-08-06 (follow-up z §8, DoD (d) niżej) | | 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | **WYKONANE** | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: `document_chunk` gmail = 33 871 (6 398 z embeddingiem + 27 473 `newsletter`) — zgodne co do sztuki z tabelą §7 | | 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | **WYKONANE** (PASS) | §8 „Wynik bramki"; `56f64e9` (eval + queries.yaml dla hybrid), `bce635c` (`mail_hit@3`, próg N2, werdykt PASS), `71eb264` (`--transport http`) | | 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **WYKONANE** (2026-08-06) | §9 „Wynik Etapu B"; żywa baza: 389 012 chunków, 0 nie-excluded bez embeddingu. Weryfikacja plastrami 0-4 (wszystkie EXIT 0) + fix NUL `4ec0b78`; `docs/sessions/2026-08-06-kb-etapb-backfill.md` | @@ -741,6 +743,20 @@ hybrid, (c) bramka §8 PASS wraz z regresją po Etapie B, (d) `kb-query` domyślnie odpowiada trybem hybrid na `kb.kapala.org`, (e) koperty gmail mają `entities[type=threading]`. +**(d) SPEŁNIONE w repo 2026-08-06** — domyślny `mode` w `/search` przełączony +`cascade` → `hybrid` (`services/kb-query/app/main.py`, walidator `Query`; +frontend przestał wysyłać `mode` przy odznaczonym „tryb flat", więc UI +dziedziczy domyślny tryb API). Podstawa: eval na **pełnym** korpusie +(187 025 zembedowanych chunków mailowych w HNSW, §9) — +kryterium 1 (regresja paperless) **PASS**: żaden istniejący hit nie +zdegradował ani 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` i `eval-direct-2026-08-06.json` +w `~/kb/mail/ingest-logs` na PIHA (celowo niecommitowane — artefakt runu). +Jawne `?mode=cascade` / `?mode=flat` działają bez zmian. +**Deploy na PIHA robi operator z mastera po mergu** — do tego czasu +`kb.kapala.org` nadal odpowiada kaskadą. + ## 13. Szacunki zbiorcze - **Dane**: +~496k wierszy `document_chunk` (~271k z embeddingiem, ~225k diff --git a/kb/services/kb-query.md b/kb/services/kb-query.md index 0b086ea..465c96a 100644 --- a/kb/services/kb-query.md +++ b/kb/services/kb-query.md @@ -3,7 +3,7 @@ okf: "0.1" type: service visibility: private status: active -updated: 2026-07-29 +updated: 2026-08-06 links: - ../runbooks/kb-query-deploy.md --- @@ -23,7 +23,23 @@ that's phase 5. | `/` | 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", "fallback_status": "up"\|"down"\|"unconfigured"}` — `sol_status` comes from the embed router's ~30 s-cached SOLARIA probe (same world view `/search` routes by), `fallback_status` is a live cheap probe of ollama-piha; no auth required (monitoring must reach it) | -| `/search?q=&mode=cascade\|flat` | GET | `query_text -> embed -> cascade_query/flat_query -> results`, `mode` defaults to `cascade` | +| `/search?q=&mode=hybrid\|cascade\|flat` | GET | `query_text -> embed -> hybrid/cascade/flat retrieval -> results`, `mode` **defaults to `hybrid`** (od 2026-08-06 — patrz „Tryby wyszukiwania" niżej) | + +### Tryby wyszukiwania (`mode`) + +| `mode` | Co robi | Kiedy | +|---|---|---| +| `hybrid` (**domyślny** od 2026-08-06) | kaskada (prefiltr po `document_summary`) **+** równoległe skanowanie chunków kopert ze źródeł bez streszczeń (`gmail`), merge po `dist` | normalne zapytania — jedyny tryb, w którym widać treść maili | +| `cascade` | tylko tor dokumentowy (prefiltr po streszczeniach) — poprzedni domyślny | porównania/debug toru dokumentowego | +| `flat` | pojedynczy skan `document_chunk` bez prefiltru | debug jakości prefiltru | + +Domyślny `mode` przełączono na `hybrid` **2026-08-06** po ponownym przejściu +bramki jakościowej (`kb/phases/kb-m5-faza-mailowa.md` §8, DoD (d)) na pełnym +korpusie mailowym (187 025 zembedowanych chunków w HNSW): kryterium regresji +paperless PASS (żaden istniejący hit nie zdegradował), mailowe hit@3 5/5. +Koszt: jedno dodatkowe zapytanie SQL na wyszukiwanie (skan chunków źródeł +summaryless). Jawne `?mode=cascade` / `?mode=flat` działają bez zmian; nieznana +wartość `mode` to 422 (pattern `^(cascade|flat|hybrid)$`). `/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 @@ -31,7 +47,7 @@ change any field the plan §4 shape already defined): ```json { - "query": "...", "mode": "cascade", "sol_status": "up", "embed_backend": "solaria", + "query": "...", "mode": "hybrid", "sol_status": "up", "embed_backend": "solaria", "results": [ {"envelope_id": "paperless:119", "source": "paperless", "dist": 0.34, "chunk_index": 2, "text": "...", "link": "https://paper.kapala.org/documents/119/details", @@ -55,8 +71,11 @@ 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)"). +- Pole zapytania + submit (Enter lub przycisk), przełącznik trybu (checkbox + „tryb flat (debug)"). Odznaczony — czyli normalny przypadek — **nie wysyła + parametru `mode` w ogóle**, więc UI dziedziczy domyślny tryb API (`hybrid` + od 2026-08-06); zaznaczony wysyła jawne `mode=flat`. Domyślny tryb jest + zdefiniowany w jednym miejscu (serwer), nie zduplikowany w JS. - Wyniki grupowane po `envelope_id` (dokument): nagłówek trafienia to streszczenie dokumentu (`summary`, tor haiku) gdy dostępne, w przeciwnym razie `envelope_id`; chunki są rozwijanymi fragmentami (`
`) pod diff --git a/services/kb-query/app/main.py b/services/kb-query/app/main.py index 9258d9f..22ec17f 100644 --- a/services/kb-query/app/main.py +++ b/services/kb-query/app/main.py @@ -15,9 +15,13 @@ silent cross-space distance computation. 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. -`mode=hybrid` (faza mailowa, plan Krok 3, kb/phases/kb-m5-faza-mailowa.md §6) is -available explicitly starting here, but the default stays `cascade` until the quality gate -(plan §8) PASSes on the full mail corpus -- flipping the default is a separate, later change. +`mode=hybrid` (faza mailowa, plan Krok 3, kb/phases/kb-m5-faza-mailowa.md §6) is the +DEFAULT since 2026-08-06 (DoD (d) of that phase): the quality gate (§8) re-ran on the full +mail corpus (187k embedded mail chunks in HNSW) and PASSed -- no paperless hit degraded, +mail hit@3 5/5 -- so mail bodies are now visible without asking for them. Cost: one extra +SQL query per search (the summaryless-source chunk scan). `mode=cascade` (documents only, +the old default) and `mode=flat` (debug, no summary pre-filter) stay available explicitly +and behave exactly as before. """ from __future__ import annotations @@ -116,7 +120,7 @@ async def healthz() -> dict: @app.get("/search") async def search( q: str = Query(..., min_length=1), - mode: str = Query("cascade", pattern="^(cascade|flat|hybrid)$"), + mode: str = Query("hybrid", pattern="^(cascade|flat|hybrid)$"), ) -> dict: try: async with app.state.pool.acquire() as conn: diff --git a/services/kb-query/app/static/app.js b/services/kb-query/app/static/app.js index 4fae60e..923733f 100644 --- a/services/kb-query/app/static/app.js +++ b/services/kb-query/app/static/app.js @@ -12,8 +12,11 @@ const GREEN_MAX = 0.45; const YELLOW_MAX = 0.55; + // 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. function buildSearchUrl(query, mode) { - const params = new URLSearchParams({ q: query, mode: mode }); + const params = new URLSearchParams({ q: query }); + if (mode) params.set('mode', mode); return '/search?' + params.toString(); } @@ -200,7 +203,7 @@ event.preventDefault(); const query = queryInput.value.trim(); if (!query) return; - const mode = modeToggle.checked ? 'flat' : 'cascade'; + const mode = modeToggle.checked ? 'flat' : null; errorBox.hidden = true; results.innerHTML = '

Szukam…

'; diff --git a/services/kb-query/env.example b/services/kb-query/env.example index 4ae069b..cb6f543 100644 --- a/services/kb-query/env.example +++ b/services/kb-query/env.example @@ -36,6 +36,8 @@ EMBED_FALLBACK_URL=http://192.168.31.5:11434 # Optional: defaults to bge-m3. # EMBED_MODEL=bge-m3 -# document_summary.model kb-query's cascade path pre-filters on (the +# document_summary.model kb-query's cascade/hybrid stage 1 pre-filters on (the # compilation track, plan §2 D3). Optional: defaults to claude-haiku-4-5. +# The /search `mode` default itself is NOT configurable via env — it is the +# API contract (hybrid since 2026-08-06, kb/services/kb-query.md). # SUMMARY_MODEL=claude-haiku-4-5 diff --git a/services/kb-query/service.yaml b/services/kb-query/service.yaml index 701b66e..18cf4b4 100644 --- a/services/kb-query/service.yaml +++ b/services/kb-query/service.yaml @@ -38,4 +38,4 @@ service: - EMBED_HEALTH_TIMEOUT_S # optional — /api/tags probe timeout, defaults to 1.5 - EMBED_PRIMARY_TIMEOUT_S # optional — hard timeout for a primary embed call, defaults to 3 - EMBED_MODEL # optional — defaults to bge-m3; startup invariant vs document_chunk/document_summary + per-backend /api/tags check - - SUMMARY_MODEL # optional — defaults to claude-haiku-4-5; cascade stage-1 model + - SUMMARY_MODEL # optional — defaults to claude-haiku-4-5; stage-1 summary model for the cascade/hybrid paths (hybrid = default mode since 2026-08-06) diff --git a/services/kb-query/tests/frontend/app.test.js b/services/kb-query/tests/frontend/app.test.js index 0b2b124..621452b 100644 --- a/services/kb-query/tests/frontend/app.test.js +++ b/services/kb-query/tests/frontend/app.test.js @@ -27,6 +27,13 @@ test('buildSearchUrl carries mode=flat through untouched', () => { assert.equal(url, '/search?q=faktura&mode=flat'); }); +// Toggle unchecked (the normal case) sends no mode at all -- the UI inherits the API +// default (hybrid since 2026-08-06), instead of pinning a second copy of it in JS. +test('buildSearchUrl omits mode entirely when none is given', () => { + assert.equal(buildSearchUrl('faktura', null), '/search?q=faktura'); + assert.equal(buildSearchUrl('faktura'), '/search?q=faktura'); +}); + 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'); diff --git a/services/kb-query/tests/test_search.py b/services/kb-query/tests/test_search.py index 8a48ce0..12f0094 100644 --- a/services/kb-query/tests/test_search.py +++ b/services/kb-query/tests/test_search.py @@ -8,6 +8,9 @@ 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 from app.search import run_search # noqa: E402 @@ -246,3 +249,83 @@ class TestRunSearchNoGoodResults: conn, session, _FakeRouter(), "nothing matches", "cascade", "claude-haiku-4-5" ) assert result["results"] == [] + + +class _FakePool: + """asyncpg pool stand-in: `async with pool.acquire() as conn` yields the fake conn.""" + + def __init__(self, conn): + self._conn = conn + + def acquire(self): + conn = self._conn + + class _Acquire: + async def __aenter__(self): + return conn + + async def __aexit__(self, *exc_info): + return False + + return _Acquire() + + +def _client_with_fake_state(conn) -> TestClient: + # Same trick as tests/test_frontend.py: TestClient is NOT entered as a context manager, + # so `lifespan` (live DSN + Ollama) never runs and we inject app.state ourselves. + app.state.pool = _FakePool(conn) + app.state.http = _FakeSession() + app.state.embed_router = _FakeRouter() + return TestClient(app) + + +def _mixed_corpus_conn() -> _FakeConn: + """One paperless envelope reachable via the summary pre-filter + one gmail envelope + reachable ONLY through hybrid's summaryless-source branch -- so the mode a request + actually took is visible in the results, not just in the echoed `mode` field.""" + return _FakeConn( + summaries=[("paperless:1", 0.3)], + chunks_by_envelope={"paperless:1": [(0, "doc text", 0.3)]}, + envelopes={ + "paperless:1": {"source": "paperless", "entities": []}, + "": { + "source": "gmail", + "entities": [{"type": "headers", "from": None, "subject": "s", "date_raw": "d"}], + }, + }, + mail_chunks_by_source={"gmail": [("", 0, "mail text", 0.2)]}, + ) + + +class TestSearchEndpointModeDefault: + """HTTP-level contract of `mode` (kb/phases/kb-m5-faza-mailowa.md DoD (d), bramka §8 + PASS on the full corpus 2026-08-06): no `mode` parameter => hybrid.""" + + def test_default_without_mode_param_takes_the_hybrid_path(self): + client = _client_with_fake_state(_mixed_corpus_conn()) + response = client.get("/search", params={"q": "q"}) + assert response.status_code == 200 + body = response.json() + assert body["mode"] == "hybrid" + # The mail envelope is unreachable from the cascade/flat paths in this fixture, + # so its presence proves the hybrid branch really ran (not just a relabelled cascade). + assert [r["envelope_id"] for r in body["results"]] == [ + "", + "paperless:1", + ] + + def test_explicit_mode_flat_still_takes_the_flat_path(self): + client = _client_with_fake_state(_mixed_corpus_conn()) + body = client.get("/search", params={"q": "q", "mode": "flat"}).json() + assert body["mode"] == "flat" + assert [r["envelope_id"] for r in body["results"]] == ["paperless:1"] + + def test_explicit_mode_cascade_still_takes_the_cascade_path(self): + client = _client_with_fake_state(_mixed_corpus_conn()) + body = client.get("/search", params={"q": "q", "mode": "cascade"}).json() + assert body["mode"] == "cascade" + assert [r["envelope_id"] for r in body["results"]] == ["paperless:1"] + + def test_unknown_mode_is_rejected(self): + client = _client_with_fake_state(_mixed_corpus_conn()) + assert client.get("/search", params={"q": "q", "mode": "turbo"}).status_code == 422