* 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>
6.3 KiB
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(catchhttpx.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. RetryErrorcatch — tenacity оборачивает последнюю exception вRetryErrorпосле исчерпания попыток. Ловим его чтобы вернутьNone. Альтернатива —reraise=Trueв@retry, но это потребовало бы ловить конкретныеhttpxexceptions по отдельности.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 избыточен но безвреден.