* 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>
3.9 KiB
ADR-042: lazy-init embedder + reindex logging
Статус
Accepted (2026-07-26)
Контекст
src/memory/embedder.py падал с RuntimeError("OPENAI_BASE_URL env var not set") при импорте если env var не задана. Любой модуль, импортирующий embedder (index.py:8, search.py:7), падал ещё до вызова функции. TS tools, вызывающие Python через spawnSync("python3", [...]), получали traceback и падали целиком — memory backend был неработоспособен без OpenRouter credentials.
Логирование reindex отсутствовало: при таймауте/ошибке процесс молча умирал без диагностики (см. technical/openrouter-qwen3-embedding-index-timeout.md — 120с процесса timeout при batch=50 × 47 батчей × ~16с = ~750с, процесс молча завершается без вывода).
Это блокирует миграцию к единой облачной памяти с keyword fallback (issue #94): backend должен работать всегда, деградируя к keyword при отсутствии embeddings, а не падать.
Решение
-
Lazy-init embedder:
API_URL/API_KEYчитаются внутриembed_texts()при вызове, не при импорте. При отсутствииOPENAI_BASE_URL→return None(не raise). При API error после 5 retries →return None(catchhttpx.HTTPError,RetryError,ValueError,KeyError). Сигнатура:embed_texts(texts: list[str]) -> list[list[float]] | None.EMBEDDING_MODEL/BATCH_SIZEостались module-level с безопасными дефолтами ("gemini-embedding-2-preview","2048") — не падают без env. -
Reindex logging:
src/memory/index.pyпишет лог в${output_dir}/reindex.log(append mode, ISO-8601 UTC timestamps). Формат:2026-07-26T20:15:30Z start: changed=3 total=82. Логирует start, per-batch (model, status), done (total, took, changed/unchanged/deleted), failed (embeddings unavailable). Per-batch вызовыembed_textsвместо одного bulk-вызова — позволяет логировать прогресс каждого batch. -
None handling в callers:
search.pyвозвращает[]еслиembed_texts→ None.index.pyлогирует "failed: embeddings unavailable" и выходит с 0 (не пишет index.json). Caller (TS tool) делает fallback на keyword search.
Альтернативы
reraise=Trueв@retry— tenacity пробрасывает последнюю exception вместо оборачивания вRetryError. Отклонено: потребовало бы ловить конкретныеhttpxexceptions по отдельности (HTTPStatusError,ConnectError,TimeoutException), хрупче. CatchRetryError+httpx.HTTPErrorпокрывает все случаи.- Keyword fallback внутри Python (
search.py) — если embedder None, делать ripgrep вsearch.py. Отклонено: keyword fallback уже реализован в TS tool (plugin scoring logic), дублирование не нужно. Python возвращает[]— TS tool решает fallback. - Лог в memory root (
reindex.logрядом с.md) — отклонено:.rag/уже gitignored в памяти, лог рядом с memory files засоряет директорию и попадает в индексацию. Лог в.rag/изолирован. loggingmodule вместо ручного_log_line— отклонено:loggingтребует config (handlers, formatters), избыточно для одного файла._log_line= 4 строки, append mode, явный timestamp.