nanobot: 2026-09-10 12:33:37

This commit is contained in:
lachtan
2026-09-10 12:33:37 +02:00
parent a65d082b27
commit 52161b1cd3
95 changed files with 4904 additions and 6041 deletions

215
skills/wiki/README.md Normal file
View File

@@ -0,0 +1,215 @@
# wiki — jak to funguje
Hybrid hledání ve tvých vlastních poznámkách: dva git repozitáře (`index`, `travel`) plus živý
workspace nanobota. Skill je **výhradně čtecí** — do tvých repů nikdy nezapisuje a klony drží
jen jako pracovní kopii. Index je derived artifact: kdykoli se dá smazat a postavit od nuly
(u dnešního objemu jsou to desítky sekund).
## Tři vrstvy, od nejlevnější
```text
grep ripgrep přes soubory na disku žádný index, nikdy zastaralé, vidí i kód
toc katalog `files` z databáze adresář → soubor → titulek + tagy
search FTS5 (BM25) + vec0 (KNN) → RRF parafráze, když neznáš slova
```
Není to fallback řetěz, kde se jde dolů, když horní vrstva zklame — je to volba podle **tvaru
otázky**: přesný string, hostname nebo identifikátor patří do `grep`, orientace („co o tom
vůbec mám?") do `toc`, parafráze do `search`.
`grep` je tedy základ, ne poslední záchrana. Soubory na disku beztak leží, takže nestojí nic
navíc, a nad kódem je exact match to hlavní — proto se zdrojáky vůbec neindexují (viz níž).
## Index nikdy nevzniká v tahu agenta
Indexuje cron každou minutu. Agent v tahu jen **čte** hotový index.
Proč takhle: `exec` tool má timeout 60 s a plný index je minuty. Indexace v odpovědi by
navíc při nedostupné Ollamě dělala z čerstvého dokumentu FTS-only výsledek.
Drtivá většina tiků neudělá nic a **nezapíše ani řádek**. Když je `log/wiki_sync_cron.log`
prázdný, je to správný stav, ne že cron neběží.
## Co se v tom tiku vlastně děje
1. Vezme lock `wiki/.sync.lock`. Když ho drží živý běh, skončí bez výpisu. Lock po mrtvém
procesu (`pid` neexistuje nebo je starší než 30 min) si vezme zpátky a zaloguje
`WARN stale lock, reclaiming`.
2. **Levná detekce změn, bez indexace.** U git zdrojů `git ls-remote` — zjistí remote `HEAD`
**bez** `fetch`, což je na minutovou kadenci ten správný nástroj; `fetch` teprve když se
revize liší. U workspace zdroje walk a porovnání `path` + `size` + `mtime`; sha256 se
počítá jen při neshodě.
3. Nic se nezměnilo a nic nečeká na vektor → konec.
4. Změněné soubory se rozřežou na chunky a pošlou do Ollamy po 32. Pak se **doberou všechny
chunky bez vektoru**, i když se žádný soubor nezměnil — tohle je cesta zpátky
z degradovaného režimu, když byla Ollama chvíli mimo.
5. Zapíše `indexed_rev` a `last_sync_at`, přidá coverage řádek a shrnutí do
`log/wiki_sync.log`.
Selhání sítě je **per zdroj**: timeout nebo chyba gitu zaloguje `WARN`, ten zdroj přeskočí
s nezměněnou `indexed_rev` a ostatní dojedou. Bez timeoutů by visící `ls-remote` držel lock
a zablokoval i workspace zdroj, který se sítí nemá nic společného.
## Proč hybrid
Každá polovina umí něco jiného a ani jedna neumí obojí:
| | Umí | Neumí |
|---|---|---|
| **BM25** (FTS5) | exact match, čísla, jména, zkratky | parafrázi, kde se slova nepřekrývají |
| **vektory** (`vec0`) | „elektroodpad ze stavebnic" → „Mindstorms po ukončení podpory" | přesné řetězce a čísla |
Výsledky slučuje **RRF** — sečte převrácené ranky z obou seznamů. Aby to mělo definovaný
význam, musí obě poloviny řadit **tutéž množinu**: proto je jedinou retrieval jednotkou chunk,
ne soubor. Kdyby BM25 řadil soubory a vektory chunky, merge by nešlo interpretovat.
Naměřeno na reálných poznámkách: RRF dává MRR 0,367 proti 0,257 (BM25 sám) a 0,261 (vektory
samy), takže merge opravdu vyhrává. Plná čísla, včetně srovnání čtyř modelů, jsou
v `.claude/tracking/plans/final-wiki-hybrid-rag-result.md` v trackovacím repu.
Ve výstupu `search` u každého hitu stojí, která polovina ho našla (`bm25 #2, vec #1`). Hit,
na kterém se poloviny **shodnou**, má výrazně větší cenu než ten, který vytáhly jen vektory.
## Česká flexe — na co narazíš
Dotazová vrstva lepí `*` na slova od tří znaků, aby pokryla české koncovky. **Funguje to jen
napůl** a je dobré vědět jak: wildcard je **prefixový**, takže pomůže jen tehdy, když je tvoje
slovo prefixem tvaru v dokumentu. Česká flexe ale mění koncovku, ne začátek.
Naměřeno nad `travel/packaging-list.md`:
| Dotaz | Chunků v tom souboru |
|---|---|
| `cestu*` | **0** |
| `cesty*` | 3 |
| `cest*` | 4 |
Prakticky: na *„co si vzít na cestu do zahraničí"* se doslovný seznam věcí na cestu nedostal
ani do top 10. Když víš, že něco máš, a `search` to nenajde, zkus **kmen slova** (`cest`,
`záloh`) nebo rovnou `grep`. Rozhodnutí, jestli to řešit systémově, je otevřená položka
v `todo.md` trackovacího repa — každá varianta rozšiřuje kandidátní pool, takže to není
zdarma.
## Index je nápověda, ne odpověď
Dvě věci, které se snadno přehlédnou:
- **Výřez v `search` není zdroj pravdy.** Je zkrácený a index může být o minutu starší než
disk. Než se z nálezu odpovídá, má se ten soubor přečíst čerstvý — `SKILL.md` to agentovi
říká, ale platí to i pro tebe.
- **Vektorová polovina vždycky něco vrátí.** KNN nemá dolní mez podobnosti, takže na jakýkoli
dotaz nabídne svých k nejbližších chunků. Pro retrieval je to záměr (posoudit relevanci je
na čtenáři), ale znamená to, že prázdný výsledek nikdy neznamená „nic nesedí" — znamená
prázdný index.
## Co se indexuje a co ne
Rozsah řídí `wiki/config.yaml` ve třech krocích: `paths` (whitelist — co v něm není, pro index
neexistuje) → `include` (whitelist přípon, `*.md`) → `exclude` (skalpel, vyhrává nad oběma).
Proč whitelist a ne blacklist: rozhoduje **směr selhání**. U blacklistu nový adresář *tiše
vstoupí* do indexu, u whitelistu *tiše chybí*. Index je komprimovaná kopie obsahu včetně
osobních věcí, takže „tiše zaindexováno" je horší porucha — a workspace se mění i bez tebe,
Dream do něj zapisuje sám.
Proti té jediné slabině whitelistu stojí záchranná síť: sync na konci zaloguje řádek
`coverage <zdroj>: markdown outside paths in …` s top-level adresáři, které markdown mají
a žádný zdroj je nepokrývá. Adresáře, které tam vidíš pořád (`backup`, `tmp`, `skills`, …),
jsou vědomě vynechané; zajímavý je **nový** přírůstek v tom seznamu.
**Indexuje se výhradně markdown**, a to bez jakékoli detekce typu obsahu — `main.py` se
netrefí do `include: ["*.md"]` a k chunkeru se nedostane. Důvod je měřený: markdown parser
dělá z Python komentáře `# TODO: …` nadpis a `unicode61` neumí identifikátory (`send` uvnitř
`SendAsync` je miss). Kód pokrývá `grep` — jede přes celý klon včetně `.txt` a `.mhtml`.
## Kde co leží
Relativně ke workspace (`~/.nanobot/workspace`):
| Cesta | Role |
|---|---|
| `wiki/config.yaml` | zdroje a model — **jediný verzovaný soubor** pod `wiki/` |
| `wiki/index.sqlite` | `chunks` + FTS5 + vektory; derived, gitignorovaný |
| `wiki/remote/<id>/` | non-bare klony git zdrojů (working tree, aby měl `grep` co číst) |
| `wiki/.sync.lock` | JSON `{pid, started_at}`, jen když sync běží |
| `log/wiki_sync.log` | jeden řádek na událost — co se naindexovalo, `WARN`, coverage |
| `log/wiki_sync_cron.log` | stdout cronu; **prázdný je správně** |
| `skills/wiki/scripts/` | `wiki_sync.py` (indexace) a `wiki_search.py` (dotazy) |
Klon je záměrně non-bare: `--mirror` nemá working tree, takže by `grep` neměl co číst.
Cena je dvojnásobek místa na disku, což je na těchhle repech nic.
## Ruční spuštění
```bash
cd ~/.nanobot/workspace
# to, co dělá cron
~/.local/bin/uv run skills/wiki/scripts/wiki_sync.py
# jen jeden zdroj
~/.local/bin/uv run skills/wiki/scripts/wiki_sync.py --source travel
# smazat index a postavit od nuly
~/.local/bin/uv run skills/wiki/scripts/wiki_sync.py --full
# dotazy
~/.local/bin/uv run skills/wiki/scripts/wiki_search.py search "zálohování" --limit 5
~/.local/bin/uv run skills/wiki/scripts/wiki_search.py toc --source travel
~/.local/bin/uv run skills/wiki/scripts/wiki_search.py toc --tag caj
~/.local/bin/uv run skills/wiki/scripts/wiki_search.py grep "WireGuard" --source index
```
`--full` potřebuješ jen po změně embedding modelu, jeho dimenzí, `query_prefix` nebo chunkeru
— tedy přesně tehdy, když dotaz začne hlásit `reindex needed`. Přeembeduje všechno, takže to
nepouštěj v tahu, kde na to někdo čeká.
Plná cesta k `uv` je tu proto, že v neinteraktivním SSH není v `PATH`. Crontab si `PATH`
nastavuje sám, takže tam stačí `uv run …`.
## Když něco nefunguje
| Hláška | Co to je |
|---|---|
| `note: embeddings unavailable … FTS-only results` | Ollama na `nvidia.hell` neodpovídá. Sync mezitím indexuje dál a vektory dosadí sám, až se vrátí. |
| `note: N chunks still awaiting vectors` | Sync je rozjezdu nebo byl degradovaný. Sémantická polovina je zatím neúplná. |
| `reindex needed: …` (exit 1) | Index byl postavený pod jiným embedding kontraktem než má config. Poznámky jsou v pořádku, řeší to `--full`. |
| `WARN <zdroj>: git … failed` | Ten zdroj se přeskočil, ostatní dojely. `indexed_rev` zůstala, takže příští tik to zkusí znovu. |
| `(no matches)` | Prázdný index — ne „nic nesedí" (viz výš). |
Všechno ostatní hledej v `log/wiki_sync.log`. Když je prázdný i po změně poznámek, problém je
v cronu, ne v hledání.
## Jak ověřit, že to funguje
**Smoke loop v konzoli** — projde celou smyčku od zápisu po nalezení:
```bash
cd ~/.nanobot/workspace
printf '# Smoke\n\nZrzavý jednorožec kontroluje wiki index.\n' > notes/_smoke.md
sleep 90
~/.local/bin/uv run skills/wiki/scripts/wiki_search.py search "zrzavý jednorožec" --limit 3
rm notes/_smoke.md # cron ho z indexu odklidí sám do minuty
```
**Dotaz do WebUI na každý zdroj.** Princip: vyber fakt, který model **nemůže** znát
z obecných znalostí — pak je konkrétní správná odpověď sama důkazem, že přišla z poznámek.
Když odpoví obecně a čísla vynechá nebo si je vymyslí, neprošel.
Příklady platné k 2026-09-09 (poznámky se mění, princip ne):
| Zdroj | Dotaz | Musí padnout |
|---|---|---|
| `workspace` | Kolik vody a čaje mám v receptu na Teh Tarik? | 700800 ml, dvě polévkové lžíce čaje |
| `travel` | Co si mám podle poznámek vzít na Sněžku? | rozdvojka do zásuvky, power banka, Sony ANC sluchátka |
| `index` | Co je projekt Sauter Bus? | RS485 proxy s odposlechem a modifikací provozu, micro:bit |
**Negativní kontrola** je nejcennější z celé sady: zeptej se na téma, které v poznámkách
prokazatelně není (např. pěstování bonsají). Správná odpověď je „nic o tom nemáš". Když
začne vyprávět, doplňuje si výsledky z obecných znalostí — a to je zrádnější vada než
nefunkční index, protože se tváří jako odpověď z poznámek.
Když si nejsi jistý, odkud odpověď je, dopiš **„ze kterého souboru to je?"**. Má přijít
konkrétní cesta, ne „z tvých poznámek".