opencode-config/docs/handoff/pr-75-rename-openai-env-chunking.md
Sergey 80a4be1d21
refactor(memory): rename to memory, OpenAI env, chunking, batching, dedup (#75)
* refactor(memory): rename package second-brain to memory

* refactor(memory): use OpenAI env naming and fix trailing slash

* feat(memory): add chunking with env-configurable size and overlap

* feat(memory): dedup search results by source in top-K

* test(memory): add chunking, batching, dedup, live tests

* docs(memory): update README and project map after rename

* docs(handoff): add handoff and ADR-032 for memory refactor

* docs(handoff): set PR number

* fix(ci): reduce index.py complexity to rank A

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-26 16:13:05 +03:00

9.5 KiB
Raw Permalink Blame History

pr title
75 Rename to memory, OpenAI env, chunking, batching, dedup

Что сделано

  • pyproject.toml: name = "second-brain"name = "memory", добавлена секция [project.scripts] rag = "src.memory.cli:main". uv.lock регенерирован (uv lock).
  • src/memory/cli.py: prog="rag"prog="memory".
  • src/memory/embedder.py: env нейминг AI_PROVIDER_API_URL/AI_PROVIDER_API_KEYOPENAI_BASE_URL/OPENAI_API_KEY (OpenAI-совместимый). API_URL.rstrip("/") фиксит trailing-slash баг (/v1//embeddings → 404). EMBEDDING_MODEL из env OPENAI_EMBEDDING_MODEL (default gemini-embedding-2-preview). BATCH_SIZE = int(os.environ.get("OPENAI_EMBEDDING_BATCH_SIZE", "2048"))embed_texts режет texts > BATCH_SIZE на батчи, конкатенирует результаты.
  • src/memory/index.py: добавлена _chunk_text(text, size, overlap) -> list[tuple[str, int]] (graceful при size<=overlap → 1 chunk). run_index использует чанки (MEMORY_CHUNK_SIZE default 512, MEMORY_CHUNK_OVERLAP default 64 из env). file_map поля: source, chunk_idx, offset, text. Пустые файлы → 1 запись с пустым чанком (не дропаются).
  • src/memory/search.py: дедуп по source в top-K — после sort по score, итерация с seen set, оставляет highest score per source, останавливается на K уникальных.
  • .env.example: добавлен блок "OpenAI Embeddings (Memory CLI)" с 6 переменными (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_EMBEDDING_MODEL, OPENAI_EMBEDDING_BATCH_SIZE, MEMORY_CHUNK_SIZE, MEMORY_CHUNK_OVERLAP). AI_PROVIDER_BASE_URL/AI_PROVIDER_API_KEY оставлены (LLM провайдер в opencode.json, отдельный concern).
  • tests/test_embedder.py: env нейминг (OPENAI_BASE_URL), + 4 теста: test_embed_texts_default_model, test_embed_texts_custom_model (reload с env override), test_embed_texts_trailing_slash (URL без //), test_embed_texts_batches (3000 texts → 2 вызова [2048, 952]).
  • tests/test_index.py: + 3 теста: test_chunking_long_file (1500 chars → 3+ records, chunk_idx/offset), test_chunking_size_env_override (MEMORY_CHUNK_SIZE=256), test_chunking_skip_rag_dir. Существующий test_index_creates_json обновлён (проверка chunk_idx/offset).
  • tests/test_search.py: + test_dedup_by_source (2 чанка одного source → 1 в top-K, highest score).
  • tests/test_chunking.py (новый): 7 edge cases _chunk_text — empty, 1 char, ровно size, size+1, unicode emoji, size<overlap, size==overlap.
  • tests/test_embedder_live.py (новый): 2 live-теста (@pytest.mark.skipif(not RUN_LIVE)) — single + batch embed.
  • README.md:40, docs/project-map/README.md:69: second-brainmemory. Project-map обновлён: embedder.pyOPENAI_BASE_URL, index.py → chunking, search.py → dedup, cli.pyprog="memory", + entries для test_embedder_live.py, test_chunking.py.
  • ADR-032 + этот handoff

Почему

7 проблем в src/memory/ (RAG CLI для opencode-memory plugin), чинятся этим PR:

  1. Нейминг-путаница: pyproject.toml name=second-brain, модуль src/memory/, CLI prog="rag" — три разных имени. Унифицировано под memory (пакет) + rag script entry point (совместимость с setup-memory.sh wrapper).
  2. Env-нейминг нестандартен: код читал AI_PROVIDER_API_URL, но .env.example/opencode.json используют AI_PROVIDER_BASE_URL — код не работал "из коробки". Перешли на OpenAI-стандарт OPENAI_BASE_URL/OPENAI_API_KEY (подтверждено openai-python docs).
  3. Trailing-slash бага: f"{API_URL}/embeddings" без .rstrip("/") → при OPENAI_BASE_URL=https://api.openai.com/v1/ URL становился /v1//embeddings → HTTP 404 (воспроизведено smoke-test'ом).
  4. Индекс 1-эмбеддинг-на-файл: 1 embedding на весь файл — нет чанков. Для 74 файлов (1.2 MB) грубо, не находит фрагменты внутри длинных файлов. Добавлено чанкирование (default 512 chars, overlap 64).
  5. Нет batching: все texts одним POST — при 3396 чанках риск упереться в OpenAI лимит (2048 input для text-embedding-3-small). Добавлен batching (default 2048).
  6. Нет дедупа в search: 2 чанка одного файла в top-K засоряли выдачу одним файлом. Добавлен дедуп по source (highest score per source).
  7. Модель захардкожена: EMBEDDING_MODEL = "gemini-embedding-2-preview" — нельзя сменить без правки кода. Вынесена в env OPENAI_EMBEDDING_MODEL.

PR #1 в серии из 2 (PR #2 — интеграция в плагин через setup-memory.sh wrapper).

Pending

  • PR #2: интеграция memory CLI в плагин @mathew-cf/opencode-memory через setup-memory.sh (замена Rust rag-cli на Python memory index/memory search). Требует: OPENAI_BASE_URL/OPENAI_API_KEY в .env, обновление wrapper в setup-memory.sh.
  • opencode.json:28 всё ещё использует AI_PROVIDER_API_KEY для LLM провайдера — отдельный concern (LLM, не embeddings), НЕ трогался в этом PR. Возможный future PR для OpenAI-стандартизации LLM env.
  • Smoke-test (3396 чанков, 1.2 MB index.json) — требует реальный API key, не запускался в CI (только unit-тесты с моками). Запустить вручную перед merge: OPENAI_BASE_URL=$AI_PROVIDER_BASE_URL OPENAI_API_KEY=$AI_PROVIDER_API_KEY uv run python -m src.memory index /root/.local/share/opencode/opencode-memory -o /tmp/.rag-test && ls /tmp/.rag-test/index.json

Watch out

  • EMBEDDING_MODEL/BATCH_SIZE — module-level, читаются при импорте. Тесты с env override (custom model, trailing slash) делают importlib.reload(embedder_mod) и восстанавливают env после теста. Если未来的 код сделает EMBEDDING_MODEL mutable через функцию — паттерн reload сломается. Альтернатива (читать env в embed_texts каждый вызов) отклонена: лишний overhead на каждый вызов + несовместимость с существующим API_URL module-level паттерном.
  • Reload-тесты меняют module globaltest_embed_texts_custom_model/test_embed_texts_trailing_slash используют monkeypatch.setenv + importlib.reload. monkeypatch auto-restore env, но reload модуля НЕ auto-restore. Явный reload в конце каждого теста восстанавливает default state. Если тест упадёт ДО финального reload — следующий тест может получить stale module state. Mitigation: monkeypatch.setenv восстанавливает env, а финальный reload в finally-стиле (после assert) — нет. Если тест упал на assert — reload не выполнится. Риск минимальный (assert в конце), но для robustness можно обернуть в try/finally — НЕ сделано для простоты.
  • Chunking size<=overlap → 1 chunk (не infinite loop). _chunk_text возвращает [(text, 0)] если size <= overlap (step = size-overlap <= 0 → range(0, len, 0) = infinite). Это graceful degradation, не ошибка. Тест test_size_less_than_overlap/test_size_equals_overlap покрывают.
  • file_map тип dict[str, str | int]chunk_idx/offset это int, source/text это str. mypy strict требует union. Существующий dict[str, str] расширен до dict[str, str | int]. JSON serialization сохраняет int как int (не string).
  • Empty files → 1 запись с пустым чанком. _chunk_text("") возвращает [], но run_index заменяет на [("", 0)] чтобы не дропать файл из индекса (поиск по empty embedding даст score 0, но файл остаётся в index для completeness). Альтернатива (дропать empty files) отклонена — меняет существующее поведение (раньше empty files попадали в индекс с 1 embedding).
  • uv lock обязателен после rename pyproject.tomluv.lock:716 содержал name = "second-brain". Без uv lock lockfile рассинхронизирован с pyproject. CI uv sync использует lockfile → несоответствие. uv lock регенерирован (confirmed: Removed second-brain v0.1.0, Added memory v0.1.0).
  • ADR number = sequential (032), НЕ PR number. Проверить ADR naming в handoff до push (эволюция паттерна PR#26 docs-reviewer typo).