* refactor(memory): lazy-init embedder env reads * feat(memory): reindex log to .rag/reindex.log * refactor(memory): search handles None embedder * test(memory): cover None embedder in search and index * docs(handoff): add handoff and ADR for lazy-init embedder * docs(handoff): set PR number * docs(handoff): rename to pr-100 prefix for pipeline detection * refactor(memory): split run_search to satisfy xenon rank A * fix(memory): cast search result fields to satisfy mypy * style(memory): ruff format search.py --------- Co-authored-by: opencode-agent <agent@opencode.local>
32 lines
No EOL
6.3 KiB
Markdown
32 lines
No EOL
6.3 KiB
Markdown
---
|
||
pr: 100
|
||
title: refactor(memory): lazy-init embedder + reindex logging
|
||
---
|
||
|
||
## Что сделано
|
||
Рефактор Python memory backend чтобы он был безопасен при отсутствии env vars и логировал reindex.
|
||
|
||
- `src/memory/embedder.py` — `API_URL`/`API_KEY` перенесены внутрь `embed_texts()` (lazy-init). `EMBEDDING_MODEL`/`BATCH_SIZE` остались module-level с безопасными дефолтами. При отсутствии `OPENAI_BASE_URL` → `return None` (не raise). При API error после 5 retries → `return None` (catch `httpx.HTTPError`, `RetryError`, `ValueError`, `KeyError`). Сигнатура: `embed_texts(texts: list[str]) -> list[list[float]] | None`. Reuse логики retries (tenacity) и batch сохранён.
|
||
- `src/memory/index.py` — добавлено логирование reindex в `${output_dir}/reindex.log` (append mode, ISO-8601 timestamps UTC). Логирует: start (changed/total), per-batch (model, status=ok/error), done (total, took, changed/unchanged/deleted), failed (embeddings unavailable). Per-batch вызовы `embed_texts` вместо одного bulk-вызова — позволяет логировать прогресс каждого batch. При `embed_texts` → None: логирует "failed: embeddings unavailable" и выходит с 0 (не падает, не пишет index.json). Helper `_log_line()` + `_embed_in_batches()`.
|
||
- `src/memory/search.py` — проверка `None` от `embed_texts`: возвращает `[]` (не падает на `[0]` index). Caller (TS tool) делает fallback на keyword.
|
||
- `tests/test_embedder.py` — `test_embed_texts_api_error` обновлён: ожидает `None` вместо `pytest.raises(HTTPStatusError)`. Добавлены `test_embed_texts_no_env_returns_none` и `test_embed_texts_empty_input`.
|
||
- `tests/test_search.py` — добавлен `test_search_returns_empty_when_embedder_none`: embedder возвращает None → search возвращает `[]`.
|
||
- `tests/test_index.py` — добавлены `test_index_writes_reindex_log` (проверяет наличие `.rag/reindex.log` со строками start/done/total) и `test_index_embedder_none_logs_and_exits_zero` (проверяет лог "failed: embeddings unavailable" + отсутствие index.json + сообщение в stdout).
|
||
|
||
## Почему
|
||
`src/memory/embedder.py` падал с `RuntimeError("OPENAI_BASE_URL env var not set")` при импорте если env не задан. Из-за этого любой модуль, импортирующий embedder (`index.py`, `search.py`), падал ещё до вызова. TS tools, вызывающие Python через `spawnSync`, получали traceback и падали целиком. Это блокировало работу memory при отсутствии OpenRouter credentials.
|
||
|
||
Логирование reindex отсутствовало — при таймауте/ошибке (см. ADR-024, PR#57; `technical/openrouter-qwen3-embedding-index-timeout.md` — 120с процесса timeout при batch=50 × 47 батчей × ~16с = ~750с) процесс молча умирал без диагностики. Лог в `.rag/reindex.log` (gitignored в памяти) даёт observable trail для дебага.
|
||
|
||
Решение возвращает `None` (не raises) чтобы caller мог сделать fallback на keyword search — это часть миграции к единой облачной памяти с keyword fallback (issue #94, ADR про keyword-vs-semantic decision в `technical/memory-keyword-vs-semantic-decision.md`).
|
||
|
||
## Pending
|
||
— Frontmatter `pr: <PR-NUMBER>` — будет заполнен номером PR после `create-pr` отдельным коммитом `docs(handoff): set PR number`.
|
||
|
||
## Watch out
|
||
- **Сигнатура `embed_texts` изменилась**: `list[list[float]]` → `list[list[float]] | None`. Все callers должны проверять `None`. В этом PR обновлены `index.py` и `search.py`. Внешних callers в репо нет (TS tools вызывают через subprocess, парсят JSON).
|
||
- **Per-batch вызовы `embed_texts`** — `_embed_in_batches` в index.py вызывает `embed_texts(batch)` в цикле вместо одного `embed_texts(all_texts)`. Каждый batch создаёт свой `httpx.Client` (внутри `embed_texts`). Для ~47 батчей это 47 TCP-соединений вместо 1. Приемлемо для offline reindex, но если потребуется оптимизация — можно вынести client наружу. Сохраняет retry-логику per-batch.
|
||
- **`RetryError` catch** — tenacity оборачивает последнюю exception в `RetryError` после исчерпания попыток. Ловим его чтобы вернуть `None`. Альтернатива — `reraise=True` в `@retry`, но это потребовало бы ловить конкретные `httpx` exceptions по отдельности.
|
||
- **`from datetime import UTC`** (не `datetime.UTC`) — `datetime.UTC` доступен только в 3.11+ как атрибут модуля, не класса. Ruff UP017 предлагает `datetime.UTC` но это требует `import datetime` (модуль). Использован `from datetime import UTC, datetime` — работает в 3.12+ (target-version).
|
||
- **Лог-файл в `.rag/`** — `reindex.log` пишется в `output_dir` (аргумент `--output`, обычно `.rag/`), НЕ в memory root. `.rag/` gitignored в памяти. Append mode — лог растёт со временем, не очищается автоматически. При необходимости добавить rotation.
|
||
- **`test_embed_texts_default_model`/`test_embed_texts_custom_model`/`test_embed_texts_trailing_slash`** используют `importlib.reload(embedder_mod)` — после рефактора reload пересчитывает `EMBEDDING_MODEL`/`BATCH_SIZE` (module-level), но `API_URL`/`API_KEY` больше не module-level. Тесты работают: env меняется через monkeypatch → `embed_texts` читает при вызове. Reload избыточен но безвреден. |