Compare commits
4 commits
473bf8e5ad
...
cb8a19de83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8a19de83 | ||
|
|
4b92924332 | ||
|
|
71eb264448 | ||
|
|
8ab262d38f |
142
docs/kb/modules/05-fallback-dedup-raport.md
Normal file
142
docs/kb/modules/05-fallback-dedup-raport.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Raport dedup: fallback embed SOLARIA→PIHA — e7625cd (master) vs 3d4ee38 (task/kb-f4-fallback)
|
||||
|
||||
**Data**: 2026-07-30 · **Worktree**: `task/kb-fallback-dedup` · **Status**: salvage **wykonany**
|
||||
2026-07-30 — operator zatwierdził pełną listę S1–S4 (sekcja „Rekomendacja zbiorcza");
|
||||
zrealizowane na tym branchu: S1 (cherry-pick `--transport http` + README), S2 (session log
|
||||
2026-07-27 z dopiskiem redakcyjnym), S3 (testy T1/T2/T3 w `test_embed_router.py`; T4
|
||||
pominięty zgodnie z raportem), S4 (wynik kalibracji w override + README ollama-piha).
|
||||
Weryfikacja: pytest kb-query 42/42 PASS; `retrieval_eval.py --transport http` przepuszczony
|
||||
end-to-end przeciwko stubowi `/search` (raport + werdykt bramki generują się poprawnie;
|
||||
kryterium 4 na stubie nie przechodzi wyłącznie dlatego, że `mail_queries` w `queries.yaml`
|
||||
mają `expected_envelope: null` — placeholdery, artefakt danych, nie kodu).
|
||||
|
||||
Dwie równoległe sesje zaimplementowały ten sam krok planu (moduł 5 faza 4, §2 decyzja 2 / §5):
|
||||
|
||||
- **e7625cd** (2026-07-29, **ZMERGOWANY do master, wdrożony na PIHA**): `app/embed_router.py`
|
||||
+ `test_embed_router.py` + `services/ollama-piha` + README z testami A/B/C.
|
||||
- **3d4ee38** (2026-07-27, `origin/task/kb-f4-fallback`, **NIEZMERGOWANY**): `app/fallback.py`
|
||||
+ `test_fallback.py` + własny `services/ollama-piha` + `retrieval_eval.py --transport http`
|
||||
+ session log z kalibracją live.
|
||||
|
||||
Master = źródło prawdy. 3d4ee38 **nie będzie mergowany** — ten raport klasyfikuje jego deltę
|
||||
i rekomenduje, co uratować.
|
||||
|
||||
---
|
||||
|
||||
## 1. Porównanie maszyn stanów (`embed_router.py` vs `fallback.py`)
|
||||
|
||||
Werdykt: **`embed_router.py` na masterze pokrywa 100% zachowań `fallback.py` i dodaje
|
||||
kilka istotnych rzeczy ponad to. Nic nie zgubił.**
|
||||
|
||||
| Zachowanie (wymóg planu §2 D2 / zadania) | `fallback.py` (branch) | `embed_router.py` (master) |
|
||||
|---|---|---|
|
||||
| Health-check cache 30 s (probe `GET /api/tags` po wygaśnięciu) | ✅ `SolCircuitBreaker`, TTL 30 s | ✅ TTL 30 s (env `EMBED_HEALTH_TTL_S`) |
|
||||
| Timeout probe'a | 0.5 s (stała) | 1.5 s (env; udokumentowane: absorbuje jitter Tailscale, spec zadania mówił „1–2 s") |
|
||||
| Twardy timeout 3 s na nodze SOLARIA | ✅ `ClientTimeout(total=3)` przez nowy param `embed_chunk(timeout_s=...)` | ✅ `asyncio.wait_for(3 s)` wokół niezmienionego `embed_chunk` — **bez ruszania pakietu** kb-retrieval; obejmuje też parsowanie odpowiedzi |
|
||||
| One-shot switch (fail mid-embed → **ten sam request** z PIHA, breaker „down" na okno TTL) | ✅ | ✅ + łapie dodatkowo `ValueError` (brak `embedding` w odpowiedzi) i loguje `circuit open for 30s` |
|
||||
| Noga PIHA bez twardego timeoutu (cold-load, ostatnia deska) | ✅ | ✅ (celowe, udokumentowane w docstringu) |
|
||||
| Powrót na SOLARIĘ ≤ 30 s po wygaśnięciu TTL | ✅ | ✅ |
|
||||
| Obsługa 503 (oba backendy padnięte → 503, nie gołe 500) | ⚠️ częściowa: tylko `aiohttp.ClientError` → 503 w `main.py`; **`TimeoutError` z nogi PIHA wyleciałby jako gołe 500** | ✅ dedykowany `EmbedBackendError` → 503; osobno pokryty przypadek „fallback nieskonfigurowany" |
|
||||
| Inwariant embed-model (query model == `document_chunk.model`) | startowy DB-check + jedna stała `embed_model` na obie nogi (dowód strukturalny w teście) | to samo **plus** leniwa weryfikacja per-backend przy pierwszym użyciu: `/api/tags` backendu musi listować `EMBED_MODEL`, inaczej `ModelMismatchError` → głośne 500 (nigdy cichy embed w złej przestrzeni wektorowej) |
|
||||
| `/healthz` dzieli cache z `/search` | ✅ `sol_status` | ✅ `sol_status` + dodatkowo `fallback_status` (up/down/unconfigured) |
|
||||
| `embed_backend` w odpowiedzi `/search` | ❌ (tylko `sol_status`) | ✅ (potrzebne do debugowania jakości per backend) |
|
||||
| Praca bez fallbacku (env nieustawiony) | placeholder `http://localhost:11434` (fail-closed, ale mylący `sol_status`) | jawny tryb `EMBED_FALLBACK_URL` unset → zachowanie sprzed Kroku 2 (503) |
|
||||
| Parametryzacja (TTL/timeouty) | stałe modułowe | env vars z defaultami |
|
||||
| Logging decyzji routingu | ❌ | ✅ (`kb-query.embed`, backend=…, elapsed_ms) |
|
||||
|
||||
Różnice czysto kosmetyczne: semantyka wygaśnięcia dokładnie na granicy TTL identyczna
|
||||
(obie strony: `t == TTL` → wygasłe); branch trzymał breaker jako dataclass z injektowalnym
|
||||
zegarem, master trzyma to samo wewnątrz `EmbedRouter` (też injektowalny zegar).
|
||||
|
||||
---
|
||||
|
||||
## 2. Pełna delta 3d4ee38 (git diff origin/master...origin/task/kb-f4-fallback) + klasyfikacja
|
||||
|
||||
| Plik | Rozmiar delty | Klasyfikacja | Rekomendacja | Uzasadnienie |
|
||||
|---|---|---|---|---|
|
||||
| `services/kb-query/app/fallback.py` | +113 | duplikat funkcji | **porzuć** | `embed_router.py` = nadzbiór (tabela §1) |
|
||||
| `services/kb-query/app/main.py` | ±32 | duplikat | **porzuć** | master ma własną, pełniejszą integrację (mapowanie 503/500, env-schemat `EMBED_PRIMARY/FALLBACK_URL`) |
|
||||
| `services/kb-query/app/search.py` | ±36 | duplikat | **porzuć** | identyczny pomysł (embed raz → `*_retrieve` z gotowym wektorem); master dodaje `embed_backend` |
|
||||
| `services/kb-query/tests/test_fallback.py` | +221 | duplikat + **3 unikalne scenariusze** | **adaptuj** | luki testowe do przeniesienia do `test_embed_router.py` — patrz §3 |
|
||||
| `services/kb-query/tests/test_search.py` | ±38 | duplikat | **porzuć** | master zaadaptował te same testy przez `_FakeRouter` (czystsze — nie dubluje maszyny stanów w testach searcha) |
|
||||
| `services/kb-query/{README,env.example,service.yaml,docker-compose.yml}` | ~±58 | duplikat | **porzuć** | inny (porzucony) schemat env `OLLAMA_PIHA_URL`; master bogatszy (testy A/B/C, rename `OLLAMA_URL`→`EMBED_PRIMARY_URL`) |
|
||||
| `packages/kb-retrieval/src/kb_retrieval/embed.py` (`timeout_s` w `embed_chunk`) | +16 | duplikat funkcji | **porzuć** | master osiąga twardy timeout przez `asyncio.wait_for` bez zmiany współdzielonego pakietu — mniejsza powierzchnia zmian, ten sam efekt |
|
||||
| `jobs/documents-ingest/eval/retrieval_eval.py` (`--transport {direct,http}` + `--base-url`) | +109 | **unikalna wartość** | **cherry-pick** | plan §2 decyzja 6 / §9 (bramka HTTP-equivalence) — **na masterze w ogóle nie istnieje**; e7625cd nie tknął tego pliku, patch aplikuje się czysto; kod woła tylko `GET /search` i czyta `envelope_id`/`dist`/`source` — w pełni zgodny z odpowiedzią mastera |
|
||||
| `jobs/documents-ingest/README.md` | +6 | **unikalna wartość** | **cherry-pick** | dokumentacja powyższego, idzie w parze |
|
||||
| `docs/sessions/2026-07-27-kb-f4-fallback.md` | +189 | **unikalna wartość** | **adaptuj** | jedyny zapis: (1) znalezisko osieroconego natywnego `ollama.service` na PIHA + jego wyłączenie 2026-07-27 i backlog odinstalowania, (2) kalibracja live ollama-piha (GO: peak ~983 MiB, ~4.2–5.3 s/embed), (3) metodologia i wyniki bramki §9 (HTTP-equivalence 0 rozbieżności; sol-down Δ~3e-4), (4) rsync-deploy → dirty working tree na PIHA. Wciągnąć z dopiskiem redakcyjnym, że zmergowana implementacja to **inny kod** (e7625cd) i wyniki bramki wymagają powtórki |
|
||||
| `services/ollama-piha/*` (5 plików) | +155 | duplikat | **porzuć** | wersja mastera lepsza: named volume `ollama_piha_models` (uzasadnienie uid-pattern PIHA), healthcheck sprawdza obecność `bge-m3`, bind tylko 127.0.0.1+LAN |
|
||||
| `hosts/piha/runtime/ollama-piha/docker-compose.override.yml` | +13 | duplikat + **1 unikalny fakt** | **adaptuj (mikro)** | ten sam `mem_limit: 2560m`; ale komentarz brancha zawiera potwierdzony pomiar (peak ~983 MiB), a master wciąż mówi „Confirm/trim after live calibration" — dopisać wynik kalibracji do komentarza override'u i/lub sekcji „Calibration" w `services/ollama-piha/README.md` |
|
||||
| `hosts/piha/services.yaml` | ±26 | duplikat | **porzuć** | master ma własny wpis `ollama-piha` + soft-dependency kb-query; drobna różnica (`offline_required: true` na branchu vs `false` na masterze) — master źródłem prawdy |
|
||||
|
||||
---
|
||||
|
||||
## 3. Pokrycie testowe — `test_embed_router.py` (13 testów) vs `test_fallback.py` (16 testów)
|
||||
|
||||
Scenariusze wspólne (pokryte po obu stronach): embed na SOLARIA gdy up; cache werdyktu
|
||||
w TTL (bez re-probe); down z probe'a → PIHA bez próby embedu na SOLARIA; down cache'owany
|
||||
(kolejne requesty omijają SOLARIĘ bez probe'a); **one-shot same-request switch** (wariant
|
||||
connection-error); **powrót na SOLARIĘ ≤30 s** (test C — po wygaśnięciu TTL); oba
|
||||
backendy padnięte → wyjątek; obie nogi z identycznym `embed_model` (na masterze mocniej:
|
||||
weryfikacja `/api/tags` per backend).
|
||||
|
||||
Master ma ponadto testy, których branch nie miał: `ModelMismatchError` (fallback bez
|
||||
bge-m3 → głośny błąd, nie cichy embed; primary bez bge-m3 → failover), brak fallbacku
|
||||
skonfigurowanego → `EmbedBackendError`, `fallback_status` (up/unconfigured), sufiks
|
||||
`:latest` spełnia gołą nazwę modelu.
|
||||
|
||||
**Luki mastera względem `test_fallback.py`** (kandydaci na salvage — pozycja „adaptuj" z §2):
|
||||
|
||||
| # | Scenariusz z `test_fallback.py` | Stan na masterze | Waga |
|
||||
|---|---|---|---|
|
||||
| T1 | One-shot switch przy **timeoutcie** mid-embed (nie tylko connection-error) — na masterze to inna ścieżka kodu (`asyncio.wait_for` → `TimeoutError`) i jest **nieprzetestowana** | brak | **wysoka** — to główny scenariusz produkcyjny („SOLARIA wisi", nie „SOLARIA odrzuca") |
|
||||
| T2 | Po one-shot switchu breaker **zostaje** „down": kolejny request w tym samym oknie TTL idzie prosto na PIHA bez probe'a i bez próby SOLARIA | brak (master testuje cache „down" tylko z probe'a, nie z mid-embed failure) | średnia |
|
||||
| T3 | Noga PIHA bez twardego timeoutu (strukturalna asercja) | brak (własność tylko udokumentowana) | niska — opcjonalnie |
|
||||
| T4 | Wygaśnięcie cache dokładnie na granicy TTL (`t == 30 s`) | brak (master testuje 31 s) | niska — semantyka i tak identyczna, można pominąć |
|
||||
|
||||
---
|
||||
|
||||
## 4. Rekomendacja zbiorcza (lista do zatwierdzenia)
|
||||
|
||||
1. **S1 — cherry-pick**: `retrieval_eval.py --transport http --base-url` + akapit w
|
||||
`jobs/documents-ingest/README.md` (plan §2 D6/§9; aplikuje się czysto, zero zależności
|
||||
od porzuconego kodu brancha).
|
||||
2. **S2 — adaptuj**: `docs/sessions/2026-07-27-kb-f4-fallback.md` → `docs/sessions/`
|
||||
z dopiskiem redakcyjnym na górze (implementacja z tej sesji porzucona na rzecz
|
||||
e7625cd; fakty operacyjne — ollama.service, kalibracja, metodologia bramki — pozostają
|
||||
w mocy).
|
||||
3. **S3 — adaptuj**: luki testowe T1 + T2 (T3 opcjonalnie) do `test_embed_router.py`.
|
||||
4. **S4 — adaptuj (mikro)**: wynik kalibracji 2026-07-27 (peak ~983 MiB, ~4.2–5.3 s,
|
||||
werdykt GO) do komentarza `hosts/piha/runtime/ollama-piha/docker-compose.override.yml`
|
||||
i sekcji Calibration w `services/ollama-piha/README.md` — pomiar dotyczył kontenera
|
||||
ollama-piha (ta sama konfiguracja: obraz, `OLLAMA_KEEP_ALIVE=0`, `mem_limit 2560m`),
|
||||
więc **przenosi się** na wersję mastera; różni się tylko storage (bind vs named
|
||||
volume), co nie wpływa na RAM/latencję.
|
||||
5. **Porzuć** całą resztę delty (kolumna „porzuć" w §2).
|
||||
|
||||
---
|
||||
|
||||
## 5. Follow-up (po salvage)
|
||||
|
||||
- **(a) Sprzątanie brancha** (wykona Oskar): skasować `origin/task/kb-f4-fallback`
|
||||
(3d4ee38) i worktree `~/homelab-codex-ws-kb-f4-fallback` po zakończeniu salvage.
|
||||
- **(b) Powtórka testu sol-down na żywym masterze**: kalibracja i bramka z 2026-07-27
|
||||
dotyczyły **innego kodu** (`fallback.py`, env `OLLAMA_PIHA_URL`) — na wdrożonym
|
||||
e7625cd trzeba przejść testy A/B/C z `services/kb-query/README.md` oraz bramkę
|
||||
`retrieval_eval.py --transport http` (po S1): HTTP-equivalence przy SOLARIA-up
|
||||
(identyczne `dist`) i sol-down (Δ≤epsilon, kolejność top-k identyczna; baseline
|
||||
z 27.07: Δ~3e-4). Symulacja wg README: `EMBED_PRIMARY_URL=http://192.0.2.1:11434`
|
||||
(TEST-NET), nie dotykając SOLARII.
|
||||
- **(c) Weryfikacja stanu PIHA po podwójnym deployu** (znalezisko z session loga):
|
||||
sesja 27.07 wdrożyła kod brancha przez **rsync do `~/homelab-codex-ws` na PIHA**
|
||||
(dirty working tree), a ollama-piha wystartował tam z **bind mountem**
|
||||
`/opt/homelab/data/ollama-piha`; master definiuje **named volume**
|
||||
`ollama_piha_models`. Sprawdzić: (1) working tree na PIHA czysty po merge'u e7625cd
|
||||
i kontenery faktycznie zbudowane z kodu mastera, (2) który storage żywy kontener
|
||||
naprawdę montuje; jeśli named volume — czy `bge-m3` jest w nim spullowany, a stary
|
||||
katalog bind (`/opt/homelab/data/ollama-piha`, ~1.2 GB modelu) do skasowania,
|
||||
(3) `.env` kb-query używa `EMBED_FALLBACK_URL` (schemat mastera), nie martwego
|
||||
`OLLAMA_PIHA_URL` — szybki test: `/healthz` musi zwracać `fallback_status: "up"`,
|
||||
nie `"unconfigured"`.
|
||||
- **(d) Backlog z 27.07** (przetrwa tylko dzięki S2): natywny, osierocony
|
||||
`ollama.service` na PIHA wyłączony (`systemctl disable --now`) 2026-07-27 —
|
||||
odinstalować binarkę/unit po ~2 tygodniach ciszy (≈ 2026-08-10).
|
||||
203
docs/sessions/2026-07-27-kb-f4-fallback.md
Normal file
203
docs/sessions/2026-07-27-kb-f4-fallback.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# Sesja 2026-07-27 — KB faza 4: fallback embed SOLARIA→PIHA (krok 3, ostatni element rdzenia)
|
||||
|
||||
> **Dopisek redakcyjny (2026-07-30, dedup — `docs/kb/modules/05-fallback-dedup-raport.md`):**
|
||||
> implementacja kodu z tej sesji (`app/fallback.py`, branch `task/kb-f4-fallback`, 3d4ee38)
|
||||
> została **porzucona** — do mastera weszła równoległa, szersza implementacja tego samego
|
||||
> kroku planu (e7625cd, `app/embed_router.py`, 2026-07-29) i to ona biega na PIHA. Ten log
|
||||
> wciągnięto do repo, bo dokumentuje fakty operacyjne niezależne od porzuconego kodu:
|
||||
> znalezisko i wyłączenie osieroconego natywnego `ollama.service` na PIHA (§3, z backlogiem
|
||||
> odinstalowania ≈2026-08-10), kalibrację live ollama-piha z werdyktem GO (§4 — konfiguracja
|
||||
> kontenera identyczna na masterze, pomiar przenosi się) oraz metodologię i baseline bramki
|
||||
> §9 (§5, Δ~3e-4). Wyniki bramki i testu sol-down dotyczyły kodu z brancha — na wdrożonym
|
||||
> masterze wymagają powtórki (raport dedup, follow-up (b)). Sekcje o deployu (§6) i
|
||||
> "Do zrobienia przez operatora" pkt 1–2 opisują stan sprzed merge'a e7625cd — historyczne.
|
||||
> Z delty brancha uratowano ponadto: `retrieval_eval.py --transport http` (plan §2 D6/§9)
|
||||
> i luki testowe T1/T2 przeniesione do `test_embed_router.py`.
|
||||
|
||||
**Zakres**: `docs/kb/modules/05-faza4-plan.md` §2 decyzja 2 / §5 — aktywny fallback
|
||||
embedu, ostatni brakujący element rdzenia fazy 4 (frontend i ingress LIVE od
|
||||
2026-07-22/23, `docs/sessions/2026-07-23-kb-f4-ingress.md`). Zero zmian w schemacie
|
||||
DB, zero zmian w `kb_retrieval`'s retrieval logice — wyłącznie warstwa embed + health.
|
||||
|
||||
Praca wykonana w task worktree (`task/kb-f4-fallback`, `.claude/skills/worktree-aware`).
|
||||
Zgodnie z ustaleniem na starcie sesji (patrz "Ustalenia proceduralne" niżej): kod
|
||||
napisany i przetestowany lokalnie w worktree, produkcyjne kroki (kalibracja, deploy,
|
||||
live-test) wykonane po jawnej zgodzie operatora, z osobnym potwierdzeniem przed
|
||||
każdym kolejnym krokiem dotykającym PIHA/SOLARIĘ.
|
||||
|
||||
## Ustalenia proceduralne
|
||||
|
||||
Zadanie wprost wymagało kroków produkcyjnych (kalibracja RAM/latencji na żywym PIHA,
|
||||
symulacja sol-down dotykająca SOLARII, deploy, push) — sprzeczne z ogólną dyscypliną
|
||||
`worktree-aware` ("nigdy nie uruchamiaj deployów/healthchecków przeciw produkcji z
|
||||
worktree"). Zamiast rozstrzygać to samodzielnie, zapytano operatora:
|
||||
1. Recon read-only (bez zmian stanu) — zgoda bez pytania.
|
||||
2. Właściwe kroki produkcyjne (kalibracja, deploy, live-test, push) — operator
|
||||
potwierdził jawnie ("Yes, proceed with all of it") po zobaczeniu pełnego zakresu.
|
||||
|
||||
## 1. Kod (warstwa embed + health, zero zmian retrievalu/DB)
|
||||
|
||||
- **`packages/kb_retrieval/embed.py`**: `embed_chunk` dostał opcjonalny `timeout_s`
|
||||
(domyślnie `None`, zero zmiany zachowania istniejących wołań) — potrzebny do
|
||||
twardego 3 s timeoutu na nodze SOLARIA bez zmiany zachowania nogi PIHA.
|
||||
- **`services/kb-query/app/fallback.py`** (nowy): `SolCircuitBreaker` (cache 30 s,
|
||||
zegar wstrzykiwalny do testów) + `resolve_sol_status` (probe `/api/tags`, 500 ms) +
|
||||
`embed_with_fallback` (SOLARIA z twardym 3 s timeoutem → jednorazowe przełączenie na
|
||||
PIHA **w tym samym requeście** przy timeout/błędzie → PIHA bez dodatkowego
|
||||
timeoutu). Dokładnie maszyna stanów z planu §2 decyzja 2.
|
||||
- **`app/search.py`**: `run_search` liczy embedding raz przez `embed_with_fallback`,
|
||||
potem woła `flat_retrieve`/`cascade_retrieve`/`hybrid_retrieve` (niskopoziomowe
|
||||
funkcje `kb_retrieval`, biorą gotowy wektor) zamiast `flat_query`/`cascade_query`/
|
||||
`hybrid_query` (które embedują same) — dzięki temu decyzja fallbacku żyje wyłącznie
|
||||
w warstwie HTTP kb-query, zero zmiany w `kb_retrieval`. `sol_status` w odpowiedzi to
|
||||
teraz realny wynik, nie zahardkodowane `"up"`.
|
||||
- **`app/main.py`**: `/healthz` i `/search` dzielą jeden `SolCircuitBreaker`
|
||||
(`app.state.sol_breaker`) — oba endpointy zawsze zgadzają się co do aktualnego
|
||||
stanu. Nowy env `OLLAMA_PIHA_URL` (domyślnie `http://localhost:11434` — celowo
|
||||
"inertny" placeholder, fail-closed, dopóki operator nie ustawi realnego adresu).
|
||||
- **Inwariant modelu**: **nie dodano** drugiego, per-request sprawdzenia w DB —
|
||||
`EMBED_MODEL` to jedna stała wątkowana przez obie nogi `embed_with_fallback`,
|
||||
więc startowy check (`app/startup.py`, niezmieniony) pokrywa obie ścieżki z
|
||||
konstrukcji. Dodanie drugiego DB-checka chroniłoby przed scenariuszem, który nie
|
||||
może wystąpić (CLAUDE.md: nie dodawaj walidacji dla scenariuszy, które nie mogą się
|
||||
zdarzyć) — zamiast tego nowy test (`test_both_legs_use_identical_embed_model`)
|
||||
strukturalnie dowodzi, że obie nogi w tym samym requeście dostają identyczny
|
||||
`embed_model`.
|
||||
- **`jobs/documents-ingest/eval/retrieval_eval.py`**: dodano `--transport
|
||||
{direct,http}` + `--base-url` (plan §2 decyzja 6 / §9) — dotąd nieistniejące (tylko
|
||||
ręczny smoke-test, `docs/sessions/2026-07-23-kb-f4-ingress.md` follow-up). Tryb
|
||||
`http` woła trzy `GET /search` (flat/cascade/hybrid) na żywym kb-query zamiast
|
||||
embedować+odpytywać lokalnie; `envelope.source` do kryterium 4 bierze się z pola
|
||||
`source` w odpowiedzi JSON, nie z osobnego zapytania do DB. Nie da się swipe'ować
|
||||
N przez HTTP (kb-query serwuje jeden N per request) — tryb http raportuje tylko
|
||||
przy `--gate-n`.
|
||||
|
||||
## 2. Nowy serwis `services/ollama-piha`
|
||||
|
||||
Klon wzorca `services/ollama` (`owner_node: piha` zamiast `solaria`, bez rezerwacji
|
||||
GPU — PIHA to arm64 bez akceleracji), `OLLAMA_KEEP_ALIVE=0` (model ładowany tylko na
|
||||
czas requestu). `mem_limit: 2560m` (tentatywny wg planu, potwierdzony pomiarem —
|
||||
patrz §3). Wpisany do `hosts/piha/services.yaml` (`depends_on.local` kb-query →
|
||||
`[kb-postgres, ollama-piha]`, fallback nie jest twardą zależnością na starcie).
|
||||
|
||||
## 3. Znalezisko: osierocony natywny `ollama.service` na PIHA
|
||||
|
||||
Podczas pierwszej próby deployu `ollama-piha` (bind `127.0.0.1:11434`) — konflikt
|
||||
portu. Okazało się, że PIHA ma **natywny (nie-Docker) systemd `ollama.service`**
|
||||
(v0.6.1, `enabled`, działający od 2026-06-22, PATH env wskazujący na użytkownika
|
||||
`/home/pi/...`), o którym nic nie wiadomo w repo — plan §1.2 wprost zakładał "PIHA:
|
||||
brak Ollamy", co okazało się nieaktualne/błędne. To realna sprzeczność planu z
|
||||
rzeczywistością → STOP, pytanie do operatora zamiast cichej decyzji.
|
||||
|
||||
Weryfikacja przed jakąkolwiek akcją: `journalctl -u ollama --since "7 days ago"` —
|
||||
**tylko własne, właśnie wykonane** zapytania probe (`/api/version`, `/api/tags`),
|
||||
`total blobs: 0` od startu (nigdy nic nie pobrano). Operator potwierdził: martwy
|
||||
balast, `sudo systemctl disable --now ollama.service` (**disable, nie uninstall** —
|
||||
odwracalne). Port 11434 zwolniony, `ollama-piha` wystartował normalnie.
|
||||
|
||||
**Backlog**: PIHA host-level shadow — natywny `ollama.service` wyłączony
|
||||
2026-07-27; odinstalować binarkę/unit po ~2 tygodniach jeśli nic się nie posypie.
|
||||
|
||||
## 4. Kalibracja (plan §5, gate) — **werdykt: GO**
|
||||
|
||||
Zmierzone na żywym PIHA pod normalnym obciążeniem (kb-postgres, paperless, Immich,
|
||||
HA, Forgejo działające, nie okno nocnej ciszy), 3 kolejne wywołania `/api/embeddings`
|
||||
po `ollama pull bge-m3`:
|
||||
|
||||
| Wywołanie | Latencja |
|
||||
|---|---|
|
||||
| 1 (pierwsze, zimny start) | 5.25 s |
|
||||
| 2 | 4.41 s |
|
||||
| 3 | 4.16 s |
|
||||
|
||||
Brak przyspieszenia między wywołaniami — zgodnie z projektem (`OLLAMA_KEEP_ALIVE=0`
|
||||
zwalnia model po każdym requeście, `ollama ps` pokazuje zero rezydentnych modeli
|
||||
między wywołaniami).
|
||||
|
||||
RAM: baseline idle ~66 MiB, szczyt podczas burst ~983 MiB (`docker stats`, próbkowane
|
||||
co 0.3 s w trakcie 3 wywołań) — komfortowo w granicach ceilingu `2560m`. `free -h`
|
||||
systemowe: `available` nie spadło poniżej ~1.3 GiB w trakcie, osiadło na ~4.2 GiB po
|
||||
(dla porównania: przed startem eksperymentu `available` = 3.7 GiB).
|
||||
|
||||
**Werdykt**: oba kryteria planu spełnione (latencja pojedyncze sekundy, nie
|
||||
dziesiątki; RAM ze sporym zapasem) → **włączony jako domyślny fallback**, bez flagi
|
||||
`KB_QUERY_LOCAL_FALLBACK_ENABLED`.
|
||||
|
||||
## 5. Bramka jakościowa (plan §9)
|
||||
|
||||
Wszystko uruchomione z `~/kb/venv` na PIHA (istniejący venv z poprzednich sesji,
|
||||
`aiohttp`/`asyncpg`/`yaml` już obecne) przeciw żywej bazie + żywemu kb-query.
|
||||
|
||||
**HTTP-equivalence** (`--transport http` vs `--transport direct`, SOLARIA up, ten sam
|
||||
`--gate-n 10`): oba PASS, **0 rozbieżności** w `dist` na wszystkich zapytaniach
|
||||
(`flat_top1_dist`, `hybrid_top1_dist`, `cascade[10].top1_dist`) — identyczne bit w
|
||||
bit, jak wymagał plan (nie ±epsilon, bo to ten sam kod, HTTP to tylko opakowanie).
|
||||
|
||||
**Live sol-down fallback test**: symulacja przez `OLLAMA_URL=http://solaria:1`
|
||||
(zły port, zgodnie z rekomendacją planu — zero dotknięcia SOLARII/innych
|
||||
konsumentów Ollamy) w `.env` kb-query, restart kontenera. `/healthz` →
|
||||
`sol_status: "down"`. `/search` → 200, wyniki z PIHA, ~4.3 s (zgodnie z kalibracją).
|
||||
Pełna bramka `retrieval_eval.py --transport http` z SOLARIA-down: **PASS** —
|
||||
identyczny wzorzec hit@3 co na SOLARII, `dist` w granicach epsilon:
|
||||
|
||||
| Zapytanie | dist (SOLARIA) | dist (PIHA fallback) | Δ |
|
||||
|---|---|---|---|
|
||||
| 1 | 0.341780 | 0.341509 | 0.000271 |
|
||||
| 2 | 0.324808 | 0.324858 | 0.00005 |
|
||||
| 3 | 0.428898 | 0.429184 | 0.000286 |
|
||||
| 4 | 0.448199 | 0.447903 | 0.000296 |
|
||||
| 5 | 0.386901 | 0.386816 | 0.000085 |
|
||||
| N (negative control) | 0.598301 | 0.598017 | 0.000283 |
|
||||
| N2 (negative control borderline) | 0.529772 | 0.529530 | 0.000242 |
|
||||
|
||||
Maksymalna rozbieżność: **~3e-4** — rząd wielkości mniejszy niż oczekiwany przez plan
|
||||
(1e-3–1e-2), kolejność top-k identyczna, wynik bramki (`gate.passed`) identyczny.
|
||||
Kb-query przywrócony do normalnej konfiguracji po teście (`.env` z prawdziwym
|
||||
`OLLAMA_URL`, restart), `/healthz` z powrotem `sol_status: "up"`.
|
||||
|
||||
## 6. Deploy
|
||||
|
||||
Kod nie był jeszcze zmergowany do `master` (dyscyplina worktree: agent nigdy nie
|
||||
mergeuje/pushuje `master`) — deploy przez standardowy `deploy-node.sh`
|
||||
niedostępny bez mastera. Zamiast tego: `rsync` zmienionych plików
|
||||
(`packages/kb-retrieval`, `services/kb-query`, `services/ollama-piha`,
|
||||
`hosts/piha/runtime/ollama-piha`, `hosts/piha/services.yaml`,
|
||||
`jobs/documents-ingest/eval/retrieval_eval.py` + README) do żywego checkoutu
|
||||
`~/homelab-codex-ws` na PIHA (bez zmiany brancha — working tree pozostaje na
|
||||
`master` z niescommitowanym diffem 1:1 identycznym z tą gałęzią), potem
|
||||
standardowy `docker compose ... up -d --build` z tego miejsca. Efekt: realny,
|
||||
działający deploy, ale **repo na PIHA ma dziś dirty working tree** — wymaga domknięcia
|
||||
(patrz "Do zrobienia przez operatora" niżej).
|
||||
|
||||
Zweryfikowane: `kb-query` (healthy), `ollama-piha` (healthy, `bge-m3` w wolumenie),
|
||||
`curl https://kb.kapala.org/healthz` → `200 {"sol_status":"up"}`,
|
||||
`curl https://kb.kapala.org/search?q=test` → `200`.
|
||||
|
||||
## Stan na koniec sesji
|
||||
|
||||
| Element | Status |
|
||||
|---|---|
|
||||
| `packages/kb-retrieval` — `embed_chunk(timeout_s=...)` | ✅ kod + testy |
|
||||
| `services/kb-query/app/fallback.py` — maszyna stanów | ✅ kod + testy (38/38 kb-query, 25/25 kb-retrieval) |
|
||||
| `services/ollama-piha` — nowy serwis GitOps | ✅ zdefiniowany, ✅ LIVE na PIHA |
|
||||
| Natywny `ollama.service` na PIHA (osierocony) | ✅ wyłączony (nie odinstalowany) |
|
||||
| Kalibracja RAM/latencja | ✅ zmierzone — werdykt GO |
|
||||
| `retrieval_eval.py --transport http` | ✅ zaimplementowane, ✅ PASS na żywo |
|
||||
| Live sol-down fallback test | ✅ PASS, Δ~3e-4 |
|
||||
| Deploy kb-query + ollama-piha na PIHA | ✅ LIVE, working tree PIHA dirty (patrz niżej) |
|
||||
| Merge do `master` | ⛔ nie wykonany (dyscyplina worktree — operator) |
|
||||
|
||||
## Do zrobienia przez operatora
|
||||
|
||||
1. **Merge** `task/kb-f4-fallback` → `master` (`scripts/dev/agent.sh merge` albo
|
||||
ręcznie) — branch popchnięty do `origin/task/kb-f4-fallback` (patrz commit poniżej).
|
||||
2. Na PIHA: `cd ~/homelab-codex-ws && git status` będzie dirty (diff identyczny z tym
|
||||
commitem, bo już wdrożony ad-hoc przez `rsync` w tej sesji) — po mergu do mastera,
|
||||
`git checkout -- .` (working tree już ma dokładnie tę treść) albo zwyczajnie
|
||||
`git pull` po mergu powinien wylądować "already up to date"/no-op, bo pliki na
|
||||
dysku już są zgodne z tym co przyjdzie z mastera. **Zweryfikować** `git diff` jest
|
||||
puste po pull, nie zakładać.
|
||||
3. Backlog: natywny `ollama.service` na PIHA wyłączony `systemctl disable --now`
|
||||
2026-07-27 (§3 wyżej) — jeśli nic się nie posypie przez ~2 tygodnie, odinstalować
|
||||
binarkę/unit całkiem.
|
||||
4. OIDC dla kb-query nadal odłożone (decyzja z 2026-07-23) — nie w zakresie tej sesji.
|
||||
|
|
@ -10,8 +10,11 @@ services:
|
|||
ollama-piha:
|
||||
# Plan §2 D2 starting value — the cgroup OOM killer restarts this container
|
||||
# on breach instead of the host OOM killer picking a victim (which could be
|
||||
# Home Assistant). Confirm or trim after live calibration (plan §5 step 4:
|
||||
# a few embeds + docker stats at a normal-load hour).
|
||||
# Home Assistant). Confirmed by live calibration 2026-07-27 (plan §5 step 4,
|
||||
# docs/sessions/2026-07-27-kb-f4-fallback.md §4): peak ~983 MiB during an
|
||||
# embed burst at a normal-load hour, ~66 MiB idle — comfortably inside this
|
||||
# ceiling, verdict GO. Same container config as measured (image,
|
||||
# OLLAMA_KEEP_ALIVE=0, this limit), so the number carries over.
|
||||
mem_limit: 2560m
|
||||
# Deliberately no mem_reservation: the working set is a transient spike and
|
||||
# the idle daemon is ~100 MB — soft-reserving gigabytes would permanently
|
||||
|
|
|
|||
|
|
@ -517,6 +517,12 @@ python eval/retrieval_eval.py --dsn postgresql://kb:<pw>@piha:5433/kb \
|
|||
--ollama-url http://solaria:11434 --n-sweep 5,10,20
|
||||
```
|
||||
|
||||
`--transport http --base-url http://<kb-query-host>:8230` (module 5 phase 4 plan §2 decision 6 /
|
||||
§9) calls a live `kb-query`'s `/search` instead of embedding+querying locally — no `--dsn`
|
||||
needed, `--n-sweep` is ignored (kb-query serves one server-side default N per request). Gate
|
||||
criterion: `dist` must be **identical** to the same run with `--transport direct` against the
|
||||
same live SOLARIA (same DB, same retrieval code — HTTP is only a wrapper).
|
||||
|
||||
**Result (2026-07-17, live run)**: PASS at N=10, k=5 — see plan §6.3 for the full table,
|
||||
the N-sweep calibration (N=5 is the measured safety floor; the plan's N=10 default carries a
|
||||
2× margin), and the cost/improvement analysis. `cascade_query` (N=10, k=5,
|
||||
|
|
|
|||
|
|
@ -23,9 +23,23 @@ mocked test). Runs every query in `queries.yaml`'s `queries:` list through flat
|
|||
writes nothing. Query embeddings go through Ollama on localhost/SOLARIA (bge-m3), same as
|
||||
`chunk_embed.py`/`summarize.py`.
|
||||
|
||||
`--transport {direct,http}` (module 5 phase 4 plan §2 decision 6 / §9, added alongside the
|
||||
fallback task): `direct` (default) is everything above, unchanged. `http` instead calls
|
||||
`GET {base_url}/search?q=...&mode=flat|cascade|hybrid` on a live `kb-query` and reshapes its
|
||||
JSON `results` into the same `{"chunks": [...]}` shape the direct-mode functions return, so
|
||||
`summarize_query_result`/`evaluate_gate` run identically either way. The gate criterion (plan
|
||||
§9): `dist` for `http` must be **identical** to `direct` against the same live SOLARIA -- same
|
||||
DB, same retrieval code, HTTP is only a wrapper, so any difference is a serialization/handler
|
||||
bug, never expected numerical drift. `http` mode cannot sweep `N` (kb-query serves one
|
||||
server-side default per request, plan §4) -- it reports only at `--gate-n`, and needs no `--dsn`
|
||||
(kb-query already owns the DB connection; `envelope.source` for criterion 4 comes straight from
|
||||
each result's `source` field instead of a separate DB lookup).
|
||||
|
||||
Usage:
|
||||
python retrieval_eval.py --dsn postgresql://kb:<pw>@piha:5433/kb \\
|
||||
--ollama-url http://solaria:11434 --n-sweep 5,10,20
|
||||
python retrieval_eval.py --transport http --base-url http://192.168.31.5:8230 \\
|
||||
--gate-n 10
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -156,6 +170,44 @@ async def run_query_all_tracks(
|
|||
return {"query": query, "flat": flat, "cascades": cascades, "hybrid": hybrid}
|
||||
|
||||
|
||||
async def call_search_http(
|
||||
session: aiohttp.ClientSession, base_url: str, query_text: str, mode: str
|
||||
) -> list[dict]:
|
||||
"""One `GET {base_url}/search?q=...&mode=...` call -> its `results` list. Each result
|
||||
already carries `envelope_id`/`dist`/`source` -- exactly the fields `top1_dist`/`hit_at_3`/
|
||||
`mail_hit_at_3` need, no DB lookup required on this side."""
|
||||
async with session.get(
|
||||
f"{base_url}/search", params={"q": query_text, "mode": mode}
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
return data["results"]
|
||||
|
||||
|
||||
async def run_query_all_tracks_http(
|
||||
session: aiohttp.ClientSession, base_url: str, query: dict, gate_n: int
|
||||
) -> dict:
|
||||
"""HTTP-transport equivalent of `run_query_all_tracks` -- three `/search` calls (one per
|
||||
mode) instead of embedding+querying locally. `stage1_summaries` isn't part of the HTTP
|
||||
response shape (plan §4) so it's reported empty; nothing in `evaluate_gate` reads it."""
|
||||
flat_chunks = await call_search_http(session, base_url, query["text"], "flat")
|
||||
cascade_chunks = await call_search_http(session, base_url, query["text"], "cascade")
|
||||
hybrid_chunks = await call_search_http(session, base_url, query["text"], "hybrid")
|
||||
return {
|
||||
"query": query,
|
||||
"flat": {"chunks": flat_chunks},
|
||||
"cascades": {gate_n: {"chunks": cascade_chunks, "stage1_summaries": []}},
|
||||
"hybrid": {"chunks": hybrid_chunks},
|
||||
}
|
||||
|
||||
|
||||
def envelope_sources_from_results(*chunk_lists: list[dict]) -> dict[str, str]:
|
||||
"""http transport has no DB to `fetch_envelope_sources` from -- each `/search` result
|
||||
already carries its envelope's `source`, so build the same envelope_id -> source mapping
|
||||
straight from the response bodies already fetched for this query."""
|
||||
return {c["envelope_id"]: c["source"] for chunks in chunk_lists for c in chunks}
|
||||
|
||||
|
||||
def summarize_query_result(result: dict, envelope_sources: Optional[dict[str, str]] = None) -> dict:
|
||||
query = result["query"]
|
||||
expected = query["expected_envelope"]
|
||||
|
|
@ -342,7 +394,34 @@ def print_report(
|
|||
"hybrid = 1 embed (shared) + cascade's queries + 1 extra SQL query (mail branch).")
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> dict:
|
||||
async def main_async_http(args: argparse.Namespace) -> tuple[list[dict], list[dict], list[int]]:
|
||||
"""`--transport http` path -- no DB connection, three `/search` calls per query. Returns
|
||||
only at `--gate-n` (see module docstring: kb-query serves one server-side N per request)."""
|
||||
queries = load_queries(Path(args.queries))
|
||||
mail_queries = load_mail_queries(Path(args.queries))
|
||||
n_values = [args.gate_n]
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session:
|
||||
results = []
|
||||
envelope_sources: dict[str, str] = {}
|
||||
for query in queries:
|
||||
result = await run_query_all_tracks_http(session, args.base_url, query, args.gate_n)
|
||||
envelope_sources.update(envelope_sources_from_results(
|
||||
result["flat"]["chunks"], result["cascades"][args.gate_n]["chunks"], result["hybrid"]["chunks"],
|
||||
))
|
||||
results.append(summarize_query_result(result))
|
||||
|
||||
mail_results_raw = []
|
||||
for query in mail_queries:
|
||||
result = await run_query_all_tracks_http(session, args.base_url, query, args.gate_n)
|
||||
envelope_sources.update(envelope_sources_from_results(result["hybrid"]["chunks"]))
|
||||
mail_results_raw.append(result)
|
||||
mail_results = [summarize_query_result(r, envelope_sources) for r in mail_results_raw]
|
||||
|
||||
return results, mail_results, n_values
|
||||
|
||||
|
||||
async def main_async_direct(args: argparse.Namespace) -> tuple[list[dict], list[dict], list[int]]:
|
||||
queries = load_queries(Path(args.queries))
|
||||
mail_queries = load_mail_queries(Path(args.queries))
|
||||
n_values = [int(n) for n in args.n_sweep.split(",")]
|
||||
|
|
@ -380,6 +459,21 @@ async def main_async(args: argparse.Namespace) -> dict:
|
|||
finally:
|
||||
await conn.close()
|
||||
|
||||
return results, mail_results, n_values
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> dict:
|
||||
if args.transport == "http":
|
||||
if args.n_sweep != "5,10,20": # the argparse default -- operator didn't ask for a sweep
|
||||
print(
|
||||
"note: --transport http ignores --n-sweep (kb-query serves a single "
|
||||
f"server-side default N per request); reporting only --gate-n={args.gate_n}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
results, mail_results, n_values = await main_async_http(args)
|
||||
else:
|
||||
results, mail_results, n_values = await main_async_direct(args)
|
||||
|
||||
gate_result = evaluate_gate(results, gate_n=args.gate_n, mail_rows=mail_results)
|
||||
print_report(results, n_values, gate_result, mail_rows=mail_results)
|
||||
|
||||
|
|
@ -395,19 +489,26 @@ async def main_async(args: argparse.Namespace) -> dict:
|
|||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"), help="asyncpg DSN for kb-postgres (or KB_DSN env var)")
|
||||
parser.add_argument("--transport", choices=["direct", "http"], default="direct",
|
||||
help="direct = query DB+Ollama locally (default); http = call a live kb-query's /search")
|
||||
parser.add_argument("--base-url", default=None, help="kb-query base URL, required for --transport http (e.g. http://192.168.31.5:8230)")
|
||||
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"), help="asyncpg DSN for kb-postgres (or KB_DSN env var); required for --transport direct")
|
||||
parser.add_argument("--ollama-url", default=os.environ.get("OLLAMA_URL", "http://localhost:11434"))
|
||||
parser.add_argument("--embed-model", default=DEFAULT_EMBED_MODEL)
|
||||
parser.add_argument("--summary-model", default=DEFAULT_SUMMARY_MODEL,
|
||||
help="document_summary.model to pre-filter on (plan §2 D3 resolution)")
|
||||
parser.add_argument("--k", type=int, default=DEFAULT_K)
|
||||
parser.add_argument("--gate-n", type=int, default=DEFAULT_N, help="N used for the PASS/FAIL verdict")
|
||||
parser.add_argument("--n-sweep", default="5,10,20", help="comma-separated N values to report (diagnostic)")
|
||||
parser.add_argument("--n-sweep", default="5,10,20", help="comma-separated N values to report (diagnostic; ignored by --transport http)")
|
||||
parser.add_argument("--queries", default=str(DEFAULT_QUERIES_PATH))
|
||||
parser.add_argument("--json-out", default=None, help="optional path to dump full results as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dsn:
|
||||
if args.transport == "http":
|
||||
if not args.base_url:
|
||||
print("error: --transport http requires --base-url", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.dsn:
|
||||
print("error: pass --dsn or set KB_DSN", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ real Ollama on either side. Fake backends are keyed by URL so one fake session c
|
|||
both legs; the clock is injected so the 30 s TTL is tested without sleeping."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
|
@ -18,12 +19,15 @@ FALLBACK = "http://192.168.31.5:11434"
|
|||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload=None, exc=None, status=200):
|
||||
def __init__(self, payload=None, exc=None, status=200, delay=0.0):
|
||||
self._payload = payload
|
||||
self._exc = exc
|
||||
self.status = status # check_ollama_health reads resp.status directly
|
||||
self._delay = delay
|
||||
|
||||
async def __aenter__(self):
|
||||
if self._delay:
|
||||
await asyncio.sleep(self._delay)
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
return self
|
||||
|
|
@ -43,11 +47,13 @@ class _FakeBackend:
|
|||
`embed_exc` makes /api/embeddings fail while /api/tags still answers (the plan's step-3b
|
||||
scenario: probe says up, the real embed then dies mid-query)."""
|
||||
|
||||
def __init__(self, models=("bge-m3:latest",), down=False, embed_exc=None, vector_value=0.01):
|
||||
def __init__(self, models=("bge-m3:latest",), down=False, embed_exc=None, vector_value=0.01,
|
||||
embed_delay=0.0):
|
||||
self.models = list(models)
|
||||
self.down = down
|
||||
self.embed_exc = embed_exc
|
||||
self.vector_value = vector_value
|
||||
self.embed_delay = embed_delay # seconds the /api/embeddings answer hangs before serving
|
||||
self.tags_calls = 0
|
||||
self.embed_calls = 0
|
||||
|
||||
|
|
@ -76,7 +82,8 @@ class _FakeSession:
|
|||
return _FakeResponse(exc=aiohttp.ClientConnectionError("connection refused"))
|
||||
if backend.embed_exc is not None:
|
||||
return _FakeResponse(exc=backend.embed_exc)
|
||||
return _FakeResponse({"embedding": [backend.vector_value] * 1024})
|
||||
return _FakeResponse({"embedding": [backend.vector_value] * 1024},
|
||||
delay=backend.embed_delay)
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
|
|
@ -148,6 +155,48 @@ class TestFailover:
|
|||
assert embedding[0] == 0.02
|
||||
assert solaria.embed_calls == 1
|
||||
|
||||
async def test_mid_embed_timeout_serves_same_query_from_fallback(self):
|
||||
# The step-3b guarantee for the OTHER failure shape: SOLARIA hangs instead of
|
||||
# refusing. This exercises the asyncio.wait_for path (a hang raises TimeoutError,
|
||||
# not aiohttp.ClientError) -- the hard primary timeout must cut the hang off and
|
||||
# the SAME request must still come back from the fallback.
|
||||
solaria = _FakeBackend(embed_delay=0.2)
|
||||
piha = _FakeBackend(vector_value=0.02)
|
||||
session = _FakeSession({PRIMARY: solaria, FALLBACK: piha})
|
||||
router = _router(primary_embed_timeout_s=0.05)
|
||||
embedding, backend = await router.embed(session, "q")
|
||||
assert backend == "piha"
|
||||
assert embedding[0] == 0.02
|
||||
assert solaria.embed_calls == 1
|
||||
|
||||
async def test_mid_embed_failure_opens_circuit_for_subsequent_requests(self):
|
||||
# The one-shot flip must persist: after a mid-embed failure, requests inside the
|
||||
# same TTL window go straight to the fallback -- no re-probe, no primary retry.
|
||||
solaria = _FakeBackend(embed_exc=aiohttp.ClientConnectionError("died mid-embed"))
|
||||
piha = _FakeBackend()
|
||||
clock = _FakeClock()
|
||||
router = _router(clock=clock)
|
||||
session = _FakeSession({PRIMARY: solaria, FALLBACK: piha})
|
||||
await router.embed(session, "q1") # probe says up -> embed dies -> flip to down
|
||||
tags_after_first = solaria.tags_calls
|
||||
clock.now += 10 # still inside the TTL window the flip opened
|
||||
_, backend = await router.embed(session, "q2")
|
||||
assert backend == "piha"
|
||||
assert solaria.embed_calls == 1 # primary never retried inside the window
|
||||
assert solaria.tags_calls == tags_after_first # and never re-probed either
|
||||
|
||||
async def test_fallback_embed_is_not_bounded_by_the_primary_timeout(self):
|
||||
# Last-resort semantics: a Pi-5 CPU embed plus a cold model load is legitimately
|
||||
# slow -- the fallback leg must NOT inherit the primary's hard timeout, or "slow
|
||||
# but alive" would turn back into "dead".
|
||||
solaria = _FakeBackend(down=True)
|
||||
piha = _FakeBackend(embed_delay=0.2, vector_value=0.02)
|
||||
session = _FakeSession({PRIMARY: solaria, FALLBACK: piha})
|
||||
router = _router(primary_embed_timeout_s=0.05)
|
||||
embedding, backend = await router.embed(session, "q")
|
||||
assert backend == "piha"
|
||||
assert embedding[0] == 0.02
|
||||
|
||||
async def test_down_verdict_is_cached_and_skips_primary_until_ttl_expires(self):
|
||||
solaria = _FakeBackend(down=True)
|
||||
piha = _FakeBackend()
|
||||
|
|
|
|||
|
|
@ -65,6 +65,21 @@ night-quiet) hour: run a few embeds as above while watching
|
|||
step 5: keep as default fallback / tune `mem_limit` / fall back to explicit
|
||||
503 degradation.
|
||||
|
||||
**Calibration status: measured 2026-07-27 — verdict GO** (live PIHA under
|
||||
normal load, 3 consecutive `/api/embeddings` calls after `ollama pull bge-m3`;
|
||||
full protocol in `docs/sessions/2026-07-27-kb-f4-fallback.md` §4):
|
||||
|
||||
- Latency: 5.25 s (cold start) / 4.41 s / 4.16 s — single seconds as expected,
|
||||
no warm-up between calls by design (`OLLAMA_KEEP_ALIVE=0` releases the model
|
||||
after every request, `ollama ps` shows nothing resident in between).
|
||||
- RAM: idle ~66 MiB, burst peak ~983 MiB (`docker stats` sampled at 0.3 s) —
|
||||
well inside the 2560m ceiling; host `available` never dropped below ~1.3 GiB.
|
||||
- Kept as **default fallback** (no feature flag). The measurement was taken
|
||||
against the same container configuration this repo deploys (image,
|
||||
`OLLAMA_KEEP_ALIVE=0`, `mem_limit: 2560m`), so it carries over; only the
|
||||
model storage differed (bind mount then, named volume now), which does not
|
||||
affect RAM/latency.
|
||||
|
||||
## Relation to kb-query
|
||||
|
||||
kb-query's router (`services/kb-query/app/embed_router.py`) health-checks
|
||||
|
|
|
|||
Loading…
Reference in a new issue