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

46 lines
No EOL
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
pr: 75
title: 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_KEY``OPENAI_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-brain` `memory`. Project-map обновлён: `embedder.py` `OPENAI_BASE_URL`, `index.py` chunking, `search.py` dedup, `cli.py` `prog="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 global** `test_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.toml`** `uv.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).