opencode-config/docs/handoff/pr-100-lazy-init-embedder.md
Sergey a6491df5a6
refactor(memory): lazy-init embedder + reindex logging (#100)
* 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>
2026-07-27 00:30:16 +03:00

6.3 KiB
Raw Blame History


pr: 100 title: refactor(memory): lazy-init embedder + reindex logging

Что сделано

Рефактор Python memory backend чтобы он был безопасен при отсутствии env vars и логировал reindex.

  • src/memory/embedder.pyAPI_URL/API_KEY перенесены внутрь embed_texts() (lazy-init). EMBEDDING_MODEL/BATCH_SIZE остались module-level с безопасными дефолтами. При отсутствии OPENAI_BASE_URLreturn 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.pytest_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 избыточен но безвреден.