https://raw.githubusercontent.com/ggozad/haiku.rag/HEAD/AGENTS.md
− │ ├── upgrades/ # Version migrations (v0_20_0 … v0_89_0)− - Hook points, run in configured order: `before_ingest`, `after_ingest`, `after_delete`, `before_search`, `after_search`, `lifespan`, `ingester_lifespan`. `before_ingest` refuses a document with `DocumentRejected`, and any other exception fails the ingest as `HookError`. Search hook failures propagate. `after_ingest` and `after_delete` run through `notify`, which logs a failure and never raises. A FULL rebuild passes `notify_hooks=False`.− - `QAConfig` - model, max_searches (default 5), max_executions (default 15). The budgets are per question. `max_searches` counts search *units*: searches emitted in one model response (same `RunContext.run_step`) share a unit, up to `FREE_SIBLINGS_PER_ROUND` (3) per unit, so a run searches at most `max_searches × 3` times and a sequential searcher pays one unit per search. A budget-rejected round fails its remaining siblings− `pytest` runs the whole core suite, slow and integration included. CI's fast lane is `pytest -m "not slow and not integration" --cov`. `test-windows` runs it on Windows without `--cov`, as a check that is not required. `cd evaluations && uv run pytest --cov` runs the evaluations suite and its coverage gate. Tests run under xdist (`-n auto` in addopts); pass `-n0` when debugging races or container lifecycle. Async tests are detected through pytest-asyncio's auto mode, and asyncio fixtures default− - **rapidocr 3.9.2** ships LaTeX in a docstring that beartype's import hook compiles. `pyproject.toml` carries `"ignore:invalid escape sequence:SyntaxWarning"`.− - **Maintenance** (`ingester.maintenance`, off by default): `DatabaseMaintenance` per database, built in `_apply`, run as a task only under `serve` (`_maintaining`), once per written database in `run-batch` and manifest replay, after the drain and after `_maintain_written` exits every ingester lifespan with the clients still open. A pass holds claims with `WorkerPool.pause`, a set apart from `_served`, so `resume` in a `finally` never re-serves a source that a reload unserved. `close_client` can− - **Reaping is lease-based.** Workers renew `last_heartbeat_at` every `heartbeat_interval_s`. `reap_stale` resets a claim when `COALESCE(last_heartbeat_at, claimed_at)` is older than `lease_ttl_s`, and never cancels a live coroutine. `WorkerConfig` enforces `heartbeat_interval_s <= lease_ttl_s / 3`. `claim_timeout_s` is gone.− - **`POST /sources/{id}/refresh`** runs `_sweep_once`, which skips (`refreshed: false`) while the source has pending jobs or an open breaker. Default FS `poll_interval_s` is 300.− │ │ ├── doctor.py # doctor and projection routes− │ │ └── projection.py # t-SNE of document centroids, similarity, Retained− │ └── lib/ # tested logic: addresses, chat, sessions, ingestion, inspect, map, doctor, proxy− - **Doctor and projection jobs** run on one daemon thread with its own event loop, one at a time across databases, each over the `Store` it opens. Progress reaches the request loop through `call_soon_threadsafe`. A projection's unit centroids are kept within one 256 MB budget (`Retained`).− - **The frontend proxies.** `/api/backend/*` and `/api/ingester/*` stream bodies and forward the browser's abort. The backend proxy refuses `reload`, and the ingester proxy adds `INGESTER_TOKEN` and forwards only the operator actions as writes (refresh a source, retry a job or a dead letter, cancel a job). The dev server allows the `127.0.0.1` origin.− - **The address is the inspector's state.** `app/(inspector)/layout.tsx` renders `InspectPage` for `/inspect/…` and `/search`, which reads its view from the path and the query (`lib/addresses.ts`), so it stays mounted while the address changes. `DocumentView`, `ChunkView`, `CorpusMap` and `SearchPanel` take that state as props. The corpus map job's controls, families and memory estimate live in `components/doctor/MapJob.tsx`, shared by the doctor and the inspector's map. An image search's image + │ ├── upgrades/ # Version migrations (v0_75_0 … v0_91_0)+ - Hook points, run in configured order: `before_ingest`, `after_convert`, `after_ingest`, `after_delete`, `before_search`, `after_search`, `lifespan`, `ingester_lifespan`. `before_ingest` refuses a document with `DocumentRejected`, and any other exception fails the ingest as `HookError`. `after_convert` (`ConvertedDocument`) runs between haiku.rag's conversion and chunking wherever it converts: client/documents.py (source, `create_document`, `update_document(content=)`) and FULL rebuild's stored+ - `QAConfig` - model, max_searches (default 5), max_executions (default 15), max_images (default None). The budgets are per question. `max_searches` counts search *units*: searches emitted in one model response (same `RunContext.run_step`) share a unit, up to `FREE_SIBLINGS_PER_ROUND` (3) per unit, so a run searches at most `max_searches × 3` times and a sequential searcher pays one unit per search. A budget-rejected round fails its remaining siblings+ `pytest` runs the whole core suite, slow and integration included. CI's fast lane is `pytest -m "not slow and not integration" --cov`. `test-windows` runs it on Windows without `--cov`, as a check that is not required. `test-py314` runs it on Python 3.14 without `--cov`. `cd evaluations && uv run pytest --cov` runs the evaluations suite and its coverage gate. Tests run under xdist (`-n auto` in addopts); pass `-n0` when debugging races or container lifecycle. Async tests are detected through pyt+ - **One OpenCV build.** `rapidocr` requires `opencv-python`, the GUI build, which writes the same `cv2/` files as our `opencv-python-headless` and needs X11 libraries. The root `pyproject.toml` excludes it (`exclude-dependencies`), and `tests/test_dependencies.py` checks the lock. A pip install still gets both. Uninstalling either build deletes the shared files: `uv sync --reinstall-package opencv-python-headless` restores `cv2`.+ - **rapidocr and google-genai** ship invalid escapes in string literals that beartype's import hook compiles. `pyproject.toml` carries `"ignore:.*invalid escape sequence:SyntaxWarning"`: Python 3.14 starts the message with the escape.+ - **Maintenance** (`ingester.maintenance`, off by default): `DatabaseMaintenance` per database, built in `_apply`, run as a task only under `serve` (`_maintaining`), once per written database in `run-batch` and manifest replay, after the drain and after `_maintain_written` exits every ingester lifespan with the clients still open. A pass holds claims with `WorkerPool.pause`, a set apart from `_served`, so `resume` in a `finally` never re-serves a source that a reload unserved. A pass does not wa+ - **Reaping is lease-based.** Workers renew `last_heartbeat_at` every `heartbeat_interval_s`. `reap_stale` resets a claim when `COALESCE(last_heartbeat_at, claimed_at)` is older than `lease_ttl_s`, and never cancels a live coroutine. `WorkerConfig` enforces `heartbeat_interval_s <= lease_ttl_s / 3`. The reaper passes the pool's in-flight job ids as `reap_stale(keep=)` under `claim_lock`, which `_claim` holds while it claims and tracks, so a process never reaps its own live claims, whatever its c+ - **`POST /sources/{id}/refresh`** runs `_sweep_once`, which answers `refreshed: false` for an open breaker, a failed discovery or a stopped poller. `serve`'s sweeps and refresh run whatever is queued: `uq_jobs_live` deduplicates a queued file, and an UNCHANGED event writes no revision, so a sweep never overwrites one a worker recorded meanwhile. Only `sweep_all` (`run-batch`) passes `skip_if_pending=True`, since a batch sweeps once and an event deduplicated against a live job would never be see+ - **`qa.max_images`** bounds the number of images in one request. `RAGCapability.prepare_model_request` applies it through `limit_retrieved_images` (`_tools.py`). That hook runs after every `wrap_model_request` and `before_model_request`, so it sees the capsule pictures whatever the registration order, and it changes the request only. Every image counts, tool returns included, and only label-plus-picture pairs carrying `RETRIEVED_IMAGE_TAG` are dropped: search pictures outside the last message f+ │ │ ├── doctor.py # doctor and similarity routes+ │ │ └── similarity.py # document centroids compared, near-duplicate families, Retained+ │ └── lib/ # tested logic: addresses, chat, sessions, ingestion, inspect, doctor, proxy+ - **Doctor and similarity jobs** run on one daemon thread with its own event loop, one at a time across databases, each over the `Store` it opens. Progress reaches the request loop through `call_soon_threadsafe`. A similarity job's unit centroids are kept within one 256 MB budget (`Retained`), and answer the nearest-documents lookup.+ - **The frontend proxies.** `/api/backend/*` and `/api/ingester/*` stream bodies and forward the browser's abort. The backend proxy refuses `reload`, and the ingester proxy adds `INGESTER_TOKEN` and forwards only the operator actions as writes (refresh a source, retry a job or a dead letter, cancel a job, edit or delete a document). The inspector offers document edits only on an untagged database listed by the ingester's `/sources`, and shows the ingester's answer to a save while the document st+ - **The address is the inspector's state.** `app/(inspector)/layout.tsx` renders `InspectPage` for `/inspect/…` and `/search`, which reads its view from the path and the query (`lib/addresses.ts`), so it stays mounted while the address changes. `DocumentView`, `ChunkView` and `SearchPanel` take that state as props. The similarity job's controls and families live in `components/doctor/SimilarityJob.tsx`, and the memory estimate in `components/doctor/Estimate.tsx`, shared by the doctor and the ins