From 188e0c456ac56a70819c816a84d2dcdad646b402 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 15:10:01 +0300 Subject: [PATCH 01/12] feat(search): add Phase 3 text-first retrieval and docs routing Use the SQLite/FTS5 chunk store as the default candidate source, keep dense/reranker layers optional, and add a docs-only channel selected by a BM25 score margin. Exclude experiments from the index, prune stale rows on full text reindex, and provide a graph-only rebuild utility. Tests cover the docs channel, query plan, index guard and chunk-store pruning. --- .env.example | 19 + KNOWN_ISSUES.md | 384 +++++++-------- docs/archive/KNOWN_ISSUES_2026_10.md | 273 +++++++++++ install.py | 5 +- scripts/migrate_chunks_to_sqlite.py | 56 +++ scripts/rebuild_graph.py | 67 +++ scripts/reindex_text_only.py | 183 ++++++++ scripts/smoke_e2e.py | 6 +- src/config/settings.py | 94 +++- src/core/indexing/chunk_augment.py | 4 +- src/core/indexing/chunk_store.py | 596 ++++++++++++++++++++++++ src/core/indexing/db_writer.py | 54 ++- src/core/indexing/index_pipeline.py | 47 ++ src/core/indexing/indexer.py | 101 +++- src/core/search/bm25.py | 151 +++++- src/core/search/code_tokenizer.py | 138 ++++++ src/core/search/engine.py | 361 +++++++++++++- src/core/search/fts5_mixin.py | 11 +- src/core/search/graph_adapter.py | 1 + src/core/search/ondemand_rerank.py | 15 +- src/core/search/query_plan.py | 157 +++++++ src/core/search/scoring.py | 10 + src/core/search/synonyms_ru.py | 212 +++++++++ src/core/system_artifacts.py | 22 +- src/mcp/server_tools.py | 3 + src/mcp/tools/search_tools.py | 84 +++- src/providers/reranker/llama_install.py | 12 +- tests/test_chunk_store_delete_files.py | 40 ++ tests/test_code_tokenizer.py | 22 + tests/test_experiment_output_guard.py | 16 +- tests/test_phase3_sqlite_store.py | 118 +++++ tests/test_query_plan.py | 43 ++ tests/test_search_docs.py | 64 +++ 33 files changed, 3095 insertions(+), 274 deletions(-) create mode 100644 scripts/migrate_chunks_to_sqlite.py create mode 100644 scripts/rebuild_graph.py create mode 100644 scripts/reindex_text_only.py create mode 100644 src/core/indexing/chunk_store.py create mode 100644 src/core/search/code_tokenizer.py create mode 100644 src/core/search/query_plan.py create mode 100644 src/core/search/synonyms_ru.py create mode 100644 tests/test_chunk_store_delete_files.py create mode 100644 tests/test_code_tokenizer.py create mode 100644 tests/test_phase3_sqlite_store.py create mode 100644 tests/test_query_plan.py create mode 100644 tests/test_search_docs.py diff --git a/.env.example b/.env.example index f0d06793..75371e33 100644 --- a/.env.example +++ b/.env.example @@ -297,3 +297,22 @@ # Опциональный слой tree-sitter-language-pack: +56 языков с tags.scm (symbol extraction). # Парсеры скачиваются при первом использовании (нужна сеть); queries встроены в пакет. # Установка: pip install tree-sitter-language-pack (extra: mscodebase-intelligence[language-pack]) + +# ─── Phase 3: vector-free search (SQLite/FTS5 + on-demand rerank) ─── +# EN: Chunk text/metadata live in /.mscodebase/chunks.db (SQLite FTS5). +# No vectors are stored; top-N candidates are embedded live at query time. +# RU: Текст/метаданные чанков живут в /.mscodebase/chunks.db (SQLite FTS5). +# Векторы не хранятся; top-N кандидатов эмбеддятся на лету в момент запроса. +# MSCODEBASE_SQLITE_CHUNKS=true +# MSCODEBASE_DENSE_OFF=true +# MSCODEBASE_ONDEMAND_RERANK=true +# MSCODEBASE_ONDEMAND_RERANK_TOP_N=10 +# MSCODEBASE_ONDEMAND_RERANK_MAX_CHARS=0 +# MSCODEBASE_AUGMENT_CHUNKS=true +# MSCODEBASE_DEMOTE_NON_CODE=true +# E33: demote tests/docs/experiments/fixtures in the fused pool (×0.3). +# MSCODEBASE_P3_ANCHOR=false +# E36-audit: P3 module-head anchors only with a reranker; without one +96 ms, no quality gain. +# MSCODEBASE_CODE_BM25=false +# E32: code-aware BM25 (identifier split, field boosts, proper k1/b). +# MSCODEBASE_CHUNKS_DB= # optional override; default /.mscodebase/chunks.db diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 8f180aec..3a7215a6 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1,197 +1,197 @@ -# KNOWN ISSUES — MSCodeBase Intelligence +# KNOWN ISSUES — MSCodeBase Intelligence > Синхронизируется из `AGENT_DIARY.md` при каждом [🏁 ИТОГ]. > Формат: дата | что было | статус | fix --- -## 2026-10-24 — Audit backlog: ruff 278, docstrings 196, protocol guards 7 — 🟡 tech debt (PR) - -- **ruff** (src/tests/scripts/tools): **278** errors; project-wide `ruff check .` — **1094** - (добавляются experiments/tools-скрипты). Top rules — W293 72, BLE001 63, I001 37, - F541 27, E402 26, F401 22, F841 7, F821 3, invalid-syntax 2; **176 auto-fixable**. -- **invalid-syntax**: `scripts/live_search_audit.py` (Unexpected indentation, l.64) — - скрипт не запускается и не парсится; не импортируется тестами (проверено `git grep`), - поэтому гейты зелёные. Починить в том же lint-PR. -- **flaky**: `tests/test_llama_mutex.py::test_start_sync_spawns_embedder_once_under_concurrency` - — проходит изолированно (1.66s), но падает в полном прогоне (1 failed / 2134 passed). - Класс P-005/P-011: concurrency-тест зависит от общего состояния соседних тестов; нужен - стресс-прогон ×8 до «фикса». -- **docstrings**: `scripts/check_docstrings.py` — **196** публичных функций без docstring (rc=1). -- **protocol guards**: `scripts/audit_protocol_guards.py` — 7 findings; измеренная FP-доля - **62.5% (5 из 8)** → **3 actionable** (в т.ч. `experiments/4A_unit_of_return/frozen/e7_HANDOUT_EN.recovered.md` без required field). -- **План:** отдельный PR `chore(lint): …` + `docs(docstrings): …`; гейты не отключать, - порог не поднимать. До PR — статус tech debt, не «зелёно». -- Проверено 2026-10-24: pytest 2135 passed / 7 skipped; run_all rc=0; third-party blocking=0. - ---- -## 2026-10-24 — Чужой тред dev.to (55KB) дословно лежал в трекнутых файлах публичного MIT-репо — ✅ PURGED - -- **Найдено:** аудит поднабора для XERJ Corpus Hub: 2 трекнутых артефакта (замороженный - тред dev.to, 73 комментария, 18 авторов) + инструментарий его выкачки/заморозки. - Дословный чужой текст в MIT-репо = заражение лицензией (dev.to — CC BY-NC-SA) - + согласие авторов корпуса не получено. -- **Почему R3-сканер прошёл мимо:** advisory-правило R3 смотрело объём/домены; на себе - FP/живость не замерили (P-016-класс: наличие детектора ≠ живость; §19.8). -- **Сделано 2026-10-24 (по команде владельца «вычистить всё связанное»):** - удалены из репо 2 дампа + 4 скрипта выкачки + досье активности (7 tracked файлов), - удалены 3 untracked harness-скрипта, удалён локальный сырой дамп (82 680 B, вне репо). - sha256 каждого — в карантинном манифесте: - `%LOCALAPPDATA%\mscodebase\audit-cache\purged_third_party_2026-10-24\MANIFEST.sha256`. -- **Остаток (честно):** чужие байты остаются в **git-истории**; purge рабочего дерева — - не purge истории. Фильтрация истории (force-push/BFG) не выполнялась без явной команды. -- Статус: ✅ P1 закрыт в рабочем дереве · OPEN_QUESTION: переписывать ли историю. - ---- -## 2026-10-03 — Третье состояние «я не видел» СУЩЕСТВУЕТ; сломана проводка (SUPERSEDES запись от 2026-10-03, перенесена в `KNOWN_ISSUES_2026_10.md`) - -- **Что опровергнуто (Verified 2026-10-03, перевывод из кода, не из реестра):** «в системе - есть два состояния (`PASS`/`FAIL`) и **нет третьего**» и «частичное исключение существует - только для одного гейта — это фрагмент, а не метод». Оба утверждения неверны. -- **Механизм есть и назван:** `tools/verification/gates.py:18` — коды `0` pass / `1` violation / - `2` unusable input; `:35` — `UNKNOWN = "not enough input to judge -> must never read as ALLOW"`; - `:53` G1→`UNKNOWN` на пустой выборке; `:174` G2→`UNKNOWN` на пустом клайме. - `tools/verification/g5_denominator.py:404,407` печатает `POPULATION UNDETERMINABLE` / - `POPULATION EMPTY`. Ещё 4 места печатают `POPULATION EMPTY` отказ не отдавать 0% - (`bootstrap_denominator_manifest.py:19`, `triage_protocol_findings.py:132`, - `e2e_quality_search.py:151`, `claims_audit/denominator_census.py:88`). -- **Четвёртое состояние спроектировано и заморожено:** `experiments/prompt_robustness/frozen/ - HYPOTHESES.md:49` (A4) — код возврата разведён на `0` PASS / `1` FAIL / `2` только POPULATION - ERROR / `3` UNKNOWN. -- **Корень — проводка, а не механизм (знаменатель назван):** `gates.py` не подключён **ни к одному - из 11** гейтов pre-commit; `.githooks/pre-commit:78` возвращает `False` на **любой** ненулевой - код, то есть `rc=2` («unusable input») схлопывается в тот же красный, что и настоящее нарушение. - Третье состояние существует и при этом всё равно читается как «провал». **0 из 11.** -- **Ещё 3 гейта вне цепочки:** `check_docstrings.py`, `check_lsp_health.py`, `check_zero_vectors.py` - существуют и не вызваны из `.githooks/pre-commit`. -- **Что из старой записи остаётся верным:** счётчика наблюдений своего условия нет ни у одного - гейта (0 совпадений по `observ|seen|never_fired` в `scripts/*.py`); пороги гейтов выведены - только на посаженных нарушениях, то есть benign-only. -- **Отдельно (Verified):** `src/mcp/tools/meta_tools.py:332` и `src/mcp/server_tools.py:597` — - две реализации одного вывода счётчиков; в первой `max(calls, 1)` даёт «Blocked 0.0%» при - `checks=0`, во второй явная ветка «данных нет». Починка **1 из 2**; баг присутствует во всех - 4 деревьях (`wt-gate-fix`, `wt-pr-v3`, `mscb-wt-64`, основное). -- **Побочный факт о самом реестре:** `_sync_known_issues` (`src/core/auto_doc_updater.py:372`) - дописывает записи в конец файла и **не проверяет лимит R1** — то есть автосинк сам является - драйвером переполнения. Плюс ~20 строк файла повреждены кодировкой (автосинк писал в cp866). -- **Класс:** P-019 (правило знаем — не всюду) и P-022 (результат зависит от контекста запуска). -- **Статус:** 🔴 Open (P1). Не реализовано: подключение `gates.py` к pre-commit, разведение - `rc=1`/`rc=2` в хуке, правка `meta_tools.py:332`, лимит R1 в автосинке. - - - - - - -## 2026-10-03 — Параллельные сессии без границ: 3 инцидента за один день (Open, P1) -- **Инцидент 1 (каталог-призрак).** `tools/knowledge/` (15 файлов, включая замороженный - тред и referent отрицательного результата D-05) появился в общем дереве, через ~2 минуты - исчез. Сессия объявила «данные потеряны, P1, git не вернёт». **На деле каталог цел** — - он в worktree соседней сессии (`mscb-wt-64`). Признак, который я проглядел: - `CreationTime 03.10 07:20:05` у всех 15 файлов при `LastWriteTime 01.10` = копирование. -- **Инцидент 2 (файл из чужого дерева).** В 08:38 соседняя сессия записала - `tools/verification/verify_public_claims.py` **в моё рабочее дерево** — без предупреждения. - Мой `git status` загрязнён; любой коммит собрал бы чужую работу. -- **Инцидент 3 (ветка-территория).** Та же сессия переключила дерево `mscb-wt-64` - с `feat/protocol-triage-and-t10-guards` на `main`, пока я разбирался, что делать - с неслитой веткой. Мой план P1 устарел в момент составления. -- **Общий корень:** нигде не было записано «одно дерево = одна сессия». Правило отсутствовало → - границ не существовало. Сессии не видели друг друга, но писали в общие файлы. -- **Guard:** `scripts/check_parallel_sessions.py` — 11-й pre-commit. Показывает все чужие - worktree с ветками и незакоммиченными файлами; **BLOCK**, если застейдженный файл изменён - в другом дереве или это реестр (`AGENT_DIARY`/`KNOWN_ISSUES`/`EXPERIMENTS_LOG`/`WISDOM`/`ISSUE`), - который трогает другая сессия; advisory на worktree в `%TEMP%` и на «свои реестры». - Контроль: `--selftest` positive 1/1 + negative 2/2; проверенно реальное срабатывание — - застейдженный файл соседней сессии даёт BLOCK. **Правило в AGENTS.md §7.1b + глобальный §21.** -- **Два бага гейта поймал сам и сам же починил (обязательно к изучению):** - (1) `_norm()` не был применён → гейт принимал **собственное** дерево за чужое и печатал - уверенную неправду; (2) контроль `%TEMP%` был написан от выдуманного пользователя `x`, - а не от реального `$TEMP`, поэтому «падающий» контроль не падал вовсе. **Selleftest, - построенный не на реальной среде, даёт зелёный свет ни о чём** (P-02 в новой формулировке). -- **Статус:** 🔴 Open (P1) — правила и гейт есть, но **согласования владельца не было**: - активная сессия в чужом worktree активна прямо сейчас, а `mscb-wt-64` лежит в `%TEMP%`. - - -## 2026-10-03 — Вендоренный файл Apache-2.0 без NOTICE (Open, документированное отступление) -- **Локация:** `experiments/4A_unit_of_return/frozen/crystal_catalogue_2026-09-26.md` - (5 873 B) — дословная копия `catalogue/README.md` из `Tirthahq/crystal-memory` @3e30ed2. -- **Состояние:** provenance-заголовок есть (URL, дата загрузки, лицензия, SHA), и в нём - **прямо сказано**, что NOTICE не воспроизводится. То есть это **зафиксированное отступление, - а не скрытая утечка** — ровно то, как должно выглядеть. -- **Чего не хватает:** Apache-2.0 §4(c) обязывает воспроизвести NOTICE, **если он есть у - оригинала**. Наличие NOTICE в upstream **не проверено**. В репо нет ни одного файла - третьих сторон (`THIRD_PARTY*`/`NOTICE*`/`licenses/` → 0 вхождений). -- **Guard/фикс:** проверить upstream на NOTICE; при наличии — добавить файл третьих сторон - в корень и дополнить provenance-заголовок. До проверки статус «документированное отступление». -- **Статус:** 🟡 Open, низкий риск (5.9 KB, лицензия разрешает редактирование с указанием правок). - - -## 2026-10-03 — `tools/knowledge/`: корпус не отслеживается git и шарится между двумя worktree (Open, P1) -> ⚠️ **Запись переписана в тот же день.** Первая версия утверждала «каталог исчез, вернуть -> нечем» — это была **неверная интерпретация одного снимка** (см. «Ошибка» ниже). Файлы целы. - -- **Где на самом деле:** `%TEMP%\mscb-wt-64\tools\knowledge\` — - 15 файлов, включая **`repoint_stale_refs.py`** (referent отрицательного результата **D-05** - в реестре паттернов) и второй слепок чужого треда (удалён 2026-10-24 по §20.1; - в рабочем дереве его больше нет). - В моём рабочем дереве они были видны 03.10 07:20 и исчезли ~07:22 — **`CreationTime 07:20:05` - у всех 15 при `LastWriteTime` 01.10 20:25–22:03 = сигнатура КОПИРОВАНИЯ**, а не создания. -- **Ошибка агента (моя, записано как guard):** я увидел, что каталога нет в моём дереве, и - записал «потеря данных, P1, git не вернёт». На деле он **переехал в соседний worktree** - активной сессии. **Вывод «нет в этом дереве» ≠ «нет вообще»**, когда рядом работает - параллельная сессия: единственный корректный тест — спросить соседний worktree, а не - делать вывод по одному снимку `git status`. Это ровно класс «наличие ≠ живость», но - наоборот — **отсутствие ≠ потеря**. -- **Реальный риск (остаётся):** каталог **неотслеживаем в обоих деревьях** (`?? tools/knowledge/`), - поэтому `git log`/`checkout` его не вернёт, а `git clean -fd` в любом из деревьев — **удалит - безвозвратно**. Он существует только на диске Temp. -- **Guard:** research-корпусы — **только в треке** (`experiments/**/frozen/`) с sha256-манифестом. - Плюс CI-guard «untracked-каталог с артефактами исследования дольше N часов» и, отдельно, - **`mscb-wt-64` лежит в `%TEMP%`** — Temp очищается системой; постоянные worktree туда класть нельзя. -- **Дубликат:** было два слепка одного треда (мой в audit-cache и второй в `tools/knowledge/`); - **оба удалены 2026-10-24**, sha256 — в карантинном манифесте. Урок остаётся: перед - заморозкой искать существующую копию, и держать один канон, а не два. -- **Статус:** 🔴 Open (P1, риск потери). Обнаружено и переписано 2026-10-03. - - -## 2026-10-03 — `tools/verification/`: 13 файлов верификации живут на неслитой ветке; `gate` падает на текущей (Open, P1) -- **Root Cause (Verified 2026-10-03, исправлено после первой неверной версии):** файлы - **не удалены и не потеряны**. `tools/verification/gates.py` (25 356 B) плюс ещё 12 файлов - вешают в коммите `9462dc2f` на ветке **`feat/protocol-triage-and-t10-guards`** - (есть и локально, и в `origin`), которая **не является предком HEAD** - (`git merge-base --is-ancestor` → rc=1). Текущая ветка — `chore/bump-urllib3-pyjwt-cve` - @ `d1e77fad`. Удаляющего коммита нет: `git log --diff-filter=D -- tools/verification/gates.py` пуст. -- **Почему выглядело как «исходника нет»:** компиляция `__pycache__/{gates,g5_denominator}.cpython-314.pyc` - от **01.10 23:35** — неотслеживаемый остаток от чекаута той ветки. Именно `.pyc`, а не исходник, - делает тул **видимым, но нерабочим**: MCP-тул `gate` → `rc=2: can't open file 'tools\verification\gates.py'`. -- **Влияние:** на текущей ветке любой вердикт `gate` = **BLOCK, а не ALLOW** (§19.6 — гейт, - который не может ответить, не читается как разрешение). Проверки сессии 2026-10-03 были - ручными и помечены как не-механизированные (`experiments/audit_devto_judgements/HANDOFF.md`). -- **⛔ Не делать руками:** не переписывать `gates.py` с нуля и не вытаскивать из `.pyc`. - Файл цел, лежит в git. -- **Фикс (решение владельца):** merge либо cherry-pick ветки `feat/protocol-triage-and-t10-guards`. -- **⛔ HALT — параллельная сессия:** эта ветка зачекаучена в **другом worktree** - `%TEMP%\mscb-wt-64` @ `74bfa2ad` → там работает другой агент. - Не переключать ветку и не вытягивать файлы из-под него в этом дереве (§3 HALT, - multi-agent-coordination: правка файла, редактируемого параллельно). -- **Guard (после merge):** CI-гейт «MCP-тул зарегистрирован, но его скрипт отсутствует в - рабочем дереве» — иначе класс **«наличие ≠ живость»** (`.pyc` без `.py`, неслитая ветка) - повторится молча. Отдельно: `__pycache__`-ископаемые в трек не коммитить. -- **Статус:** 🔴 Open (P1). Найдено 2026-10-03; root cause уточнён в тот же день - (первая версия записи ошибочно утверждала «исходник никогда не был в треке» — опровергнуто - `git log --all --follow`). - - -## 2026-10-03 — Тихий ноль и «свой контроль» на защите лишь частично (Open) -- **Факт (Verified 2026-10-03, grep, не по памяти):** `sys.exit(2)` — **6 мест из 73 скриптов** - в `scripts/` (`diag_quality_hang.py:261,270`, `revision_gate.py:127`, `run_1L_live_arm.py:313,317`, - `smoke_livesync.py:27`). Знаменатель: `(Get-ChildItem scripts\*.py).Count` = 73. - **Покрытие НЕ доказано** — остальные 67 могут быть защищены иначе; это не измерение доли. -- **Отсутствует вовсе (0 вхождений по репо):** маркер `coverage unknown` (печать того, чего - инструмент не видел) и assertion на совпадение популяций (`assert len(cache) == len(documents)`). -- **Отсутствует:** claims ledger (число привязано к конфигу, при которой измерено) — - `claims ledger`, `claims_ledger`, `measured under`, `live config`, `config_stale`, `stale claim` → 0. -- **Источник класса:** Tom Jones, «Implementation is where judgements go to become invisible» - (dev.to `4p1h`, 27.09.2026) + комментарии. Первоисточник удалён 2026-10-24 (§20.1); - sha256 сохранён в карантинном манифесте. -- **Статус:** 🔴 Open. Не баг, а измеренный пробел покрытия. Эксперименты E-1/E-4/E-5 и - Red Team — в `HANDOFF.md` §3/§4. +## 2026-10-25 — README rewritten; RU/ZH synchronized — 🟢 + +- Root `README.md`, `docs/ru/README.md`, `docs/zh/README.md` now describe the + Phase 3 text-first runtime (SQLite/FTS5 + PropertyGraph, optional providers), + measured quality with methodology, limitations and bug-report protocol. +- Old claims removed: LanceDB/vector as default, 65 visible tools, inconsistent + test counts, 95% quality implication. +- Historical architecture/search docs carry a Phase 3 status banner; they are + not silently presented as current. + +--- +## 2026-10-25 — E57: auto docs routing by BM25 margin (no LLM) — 🟢 + +- `docs_max - code_max > 4.0` -> docs channel, else code. +- same-basename **0.688 → 0.792**, docs 5/8, **0 code false positives**; + code hit@1 0.825 unchanged; exact 0.708; p50 ~100 ms extra. +- **Реализовано:** `MSCODEBASE_DOCS_MARGIN` (4.0), `Searcher.prefers_docs()`, + auto-ветка в `SearchCodeTool` при пустом `intent`; tests 27 pass. +- Gold canonicalization (`docs/ru/X` for `docs/en/X`) всё ещё искажает exact. + +--- +## 2026-10-25 — E55/E56: chain modes; docs gold-language bottleneck — 🟡 + +- **E55:** hybrid/quality/deep 0.688/0.771 (p50 45–48 ms); fast 0.542/0.729 + (p50 19 ms); `nodemote/intent/ru/p3` — 0 эффекта на hit@1. +- **E56:** exact-path docs_only 0.021/0.062; **translation-normalized docs_only + 0.750**, oracle **0.812/0.917**; p50 131 ms. BM25 отдаёт `docs/ru/X.md` при + gold `docs/en/X.md` — это **gold canonicalization**, не слабый docs-канал. +- **Дешёвый путь:** explicit `intent="docs"` + docs-only FTS (без LLM-router). + Параметры `demote/intent/ru/p3` на панели не помогают. +- **Реализовано:** `Searcher.search_docs()` + `search_code(intent="docs")`; + exact 1/8, same-basename **6/8**; тест `tests/test_search_docs.py`. +- **Добавлено:** language-dedupe (ru/en/zh → один документ, prefer query lang) + + `delete_files()`/prune в `scripts/reindex_text_only.py`. +- Production: graph 12,753 nodes; index 7,314 chunks (0 experiments); tests 32 pass. + +--- +## 2026-10-25 — AUDIT: graph=0, leakage, docs=0 — P0 graph fixed + +- **Graph:** production `graph.db` был 0 nodes; `index_project` блокируется + `DENSE_OFF`. Fixed: `scripts/rebuild_graph.py` → **12,753 nodes / 45,762 edges**. + Требуется рестарт MCP, чтобы сервер открыл новый graph.db. +- **Leakage:** `experiments/frozen` индексировался (19/48 BM25). Fixed: + `SystemArtifacts` исключает `experiments/**`; index очищен (0 experiments, + 7,314 chunks, FTS synced). +- **Docs:** E53/E54 — lexical/explicit intent не дают docs hit@1 (0). Нужен + отдельный docs-only FTS канал (E48: 0.75/1.00), без LLM-router. +- Baseline clean: **0.688/0.750**; code 0.825/0.900; docs 0/0. + +--- +## 2026-10-25 — E52: clean baseline — hit@1 0.688, hit@5 0.750; docs=0 — 🟡 + +- Clean index: 7,258 chunks (удалено 9,089 experiments); leakage 0/48. +- **clean production 0.688/0.750** (p50 72ms); contaminated 0.688/0.771. + **Supersede:** hit@5 0.771 → 0.750; hit@1 не изменился. +- Subsets clean: code 0.825/0.900, docs 0.000/0.000. +- **Oracle router** (code 0.825 + docs-only BM25 0.75) = **0.8125 hit@1 / + ~0.9167 hit@5**. 95% требует усиления code-канала или LLM-агента. + +--- +## 2026-10-25 — E51: frozen-панель проиндексирована (panel leakage) — 🔴 P0 + +- `experiments/4A_unit_of_return/frozen/f5/queries.md` содержит те же probes и + находится в `chunks.db`; BM25 возвращает его как top-1 почти на всех запросах. +- **E51b (фильтр `experiments/**`):** single 0.562/0.771; agent 0.438/0.792; + p50 140 ms; calls 2.0; tokens 1048. +- **Следствие:** все BM25-числа E49/E50/E51 загрязнены; production 0.688 мог + тоже быть затронут. Нужно исключить frozen-панели из индекса/retrieval и + перепроверить baseline. +- **Agentic:** детерминированный expansion+graph ухудшает hit@1; для 95% нужен + LLM-планировщик, а не фиксированная эвристика. + +--- +## 2026-10-25 — E49/E50: docs-reserve и score-router — zero-sum — 🟡 + +- **E49:** reserve15 0.250/0.500/0.583 (хуже base 0.271 на 1k); reserve25 + 0.208/0.458/0.604; oracle 0.396/0.604/0.729; p50 245 ms. +- **E50:** route@0.8–2.0 = 0.104/0.125/0.167; docs 8/8 при любом пороге; + code route 0; oracle 0.375/0.583/0.729. +- **Вывод:** docs/code конкурируют за один бюджет; лексический score не + маршрутизирует. Нужен semantic classifier/agentic loop; статическая map + даёт потолок ~37–40% на 1k, 95% недостижимо. + +--- +## 2026-10-25 — E48: two-channel map — docs BM25 сильный, routing ломает — 🟡 + +- **base** 1k/2k/4k = 0.271/0.458/0.562; **route** 0.292/0.458/0.562; + **oracle** 0.396/0.625/0.729; p50 323 ms. +- Docs-gold (8): bm25_docs 0.75/1.00/1.00; route 0.125 (2/8). +- **Потолок 1k = 39.6%**, 2k 62.5%, 4k 72.9%; 95% недостижимо. +- **Решение:** всегда docs BM25 (без keyword classifier) + top-docs под + token-reserve; code 1k улучшать BM25-top-files + PageRank hybrid. + +--- +## 2026-10-25 — E47: docs в PageRank разбавляют code-граф — 🔴 + +- **base** (code-only) 1k/2k/4k = 0.271/0.458/0.562; **docs** = 0.042/0.188/0.458; + **docs_bm25** = 0.042/0.229/0.479; p50 1,113 ms. +- Code-only 1k 32.5% → 5%; docs-only 1k 0 → 37.5% (только BM25). +- **Вывод:** docs не в тот же PageRank; два канала (code PageRank + docs BM25), + слияние по бюджету; один PageRank на запрос. H1–H7 FAIL. + +--- +## 2026-10-25 — E46/E46b: PageRank repo map — работает, но не 95% — 🟡 + +- **Граф:** 9,315 nodes / 47,286 edges / 759 code files (docs нет). +- **E46 personalized:** 1k/2k/4k = 0.271/0.417/0.521; code-only 0.325/0.500/0.625; docs 0. +- **E46b +RU→EN:** 0.271/0.458/0.562; code-only 0.325/0.550/0.675. +- **Вердикт:** PageRank ≫ degree/global, но 95% на 1k недостижимо; docs-gold вне графа. + Нужны docs-узлы, персонализация RU→EN+symbols, BM25 top-files до PageRank. +- **Артефакты:** `experiments/e46_pagerank_map/`, `e46b_pagerank_ru/`. + +--- +## 2026-10-25 — E45: structured agent plan — +1 h1, p50 72 ms — 🟢 worth API + +- **План:** primary + must_include + symbols, один запрос, без multi-query RRF. +- **Результат:** baseline 0.688/0.771/79ms; plan_must **0.708**/0.771/71ms; plan_full 0.708/0.771/72ms. +- **Вердикт:** один primary + must_include — маленький реальный выигрыш; symbols не дают. + Внедрить как опциональные поля `search_code` + tool `plan_search`. 95% не закрывает. +- **Артефакты:** `experiments/e45_agent_plan/`. + +--- +## 2026-10-25 — E44: agent rewrites — keywords +1 h1; fusion/identifiers хуже — 🟡 + +- **Плечи:** baseline / keywords / identifiers / agent_all на 48 запросах E30. +- **Результат:** baseline 0.688/0.771, 84 ms; keywords **0.708**/0.771, 78 ms; + identifiers 0.583/0.750; agent_all 0.688/0.771, 147 ms. +- **Вердикт:** одна чистая формулировка лучше флуда; агентский план должен выбирать primary query, + а не фьюзить все варианты. **Артефакты:** `experiments/e44_agent_rewrite/`. + +--- +## 2026-10-25 — E41–E43: chain audit — docs intent, experiments, concept tags — 🟡 open + +- **Диагностика:** RRF top5 35/48; 13 misses (8 docs gold, 5 recall). +- **E41:** исключение `experiments/` — 0.688/0.771 → 0.688/0.750, p50 81→43 ms. +- **E42:** intent-aware demote — без изменений; `auto_detect_intent` не распознаёт docs. +- **E43:** concept tags ×0.9 — без изменений; ×1.1 — 0.188/0.250 (precision collapse). +- **Вывод:** нужен точный symbol-level concept layer (аннотации), а не keyword-count boost. +- **Артефакты:** `experiments/e41_no_experiments/`, `e42_intent_demote/`, `e43_concepts/`. + +--- +## 2026-10-25 — E40: RU→EN синонимы — ноль — 🟡 neutral + +- **Плечи:** baseline / ru_synonyms на 48 запросах E30; `synonyms_ru.py`. +- **Результат:** оба 0.688/0.771, p50 159/176 ms. H6 PASS; H1/H2/H3/H4/H5 FAIL. +- **Вердикт:** варианты дают те же top-5; RRF схлопывает дубликаты. Словарь — утилита, + не рычаг. 95% на панели недостижимо. +- **Артефакты:** `experiments/e40_ru_synonyms/`. + +--- +## 2026-10-25 — E39: Qwen3-Reranker-0.6B — 29–55 s, хуже — 🔴 rejected + +- **Пилот:** 16 запросов E30; demote vs Qwen3 top10/top20 на corrected index. +- **Результат:** demote 0.625/0.688, 116 ms; Qwen3 top10 0.500/0.562, 28.9 s; + top20 0.438/0.562, 54.8 s. H1–H6 FAIL. +- **Вердикт:** не usable на CPU; качество ниже deterministic. Reranking не рычаг. + Deterministic 0.688/0.771/84 ms — потолок на этой панели. +- **Артефакты:** `experiments/e39_qwen3_rerank/`. + +--- +## 2026-10-25 — E38: BGE-reranker-base — хуже deterministic — 🔴 rejected + +- **Плечи:** demote / bge_base_top10 / top20 на 48 запросах E30 (corrected index). +- **Результат:** demote 0.688/0.771, 84 ms; BGE-base top10 0.625/0.708, 3139 ms; + top20 0.583/0.688, 6028 ms. H7 PASS (лучше MiniLM); остальные FAIL. +- **Вердикт:** generic cross-encoder out-of-domain для кода. Детерминированный pipeline 0.688/0.771/84 ms. + Reranking не рычаг; только Qwen3-Reranker-0.6B как пилот. +- **Артефакты:** `experiments/e38_code_aware_rerank/`. + +--- +## 2026-10-25 — E37: chunking 32/64/128 — качество не меняется; P3 off — 🟢 +- **Результат:** current/split32/split64/split128 — все h1 0.688, h5 0.771, MRR 0.713. +- Нарезка не узкое место; top-5 определяется file-level ranking. P3 off: p50 87 ms, + качество не падает. `MSCODEBASE_P3_ANCHOR=false` по умолчанию. +- **Артефакты:** `experiments/e37_chunking/`. + +--- +## 2026-10-25 — E36-audit: symbol_name пуст, P3 мёртв, graph-stage synthetic — 🟢 FIXED (index rebuilt) + +- **Root cause:** `write_text_records` читал `symbol_name` из `chunk_metadatas`, где его не было → + **0/16 158** чанков с symbol_name. FTS5/BM25 boosts, exact-name, graph hydration — мертвы. +- **Fix:** AST-symbol→line-range mapping в `IndexPipeline.process_file`; reindex → + 4 772/16 347 с symbol_name. Graph-stage: hydration + relative paths + line fallback. + P3: чтение `SQLiteChunkStore.get_file_head` вместо мёртвого `_bm25_df`. +- **Corrected E36:** full h1 0.688/h5 **0.771**/p50 183 ms; no_p3 87 ms (P3 +96 ms без качества); + no_bucket 0.562; no_o1 0.667; no_demote 0.625; minimal 0.542/0.750. +- **Остаток:** P3 отключать при выключенном reranker; E32/E33/E35 пере-прогнать на исправленном + индексе (E33 demote всё ещё +3 h1; E32/E35 без изменений). +- **Артефакты:** `experiments/e36_ablation/`. + +--- diff --git a/docs/archive/KNOWN_ISSUES_2026_10.md b/docs/archive/KNOWN_ISSUES_2026_10.md index 53c8d2fb..fdc5105b 100644 --- a/docs/archive/KNOWN_ISSUES_2026_10.md +++ b/docs/archive/KNOWN_ISSUES_2026_10.md @@ -95,3 +95,276 @@ - **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** ╤В╤А╨╕ ┬л╨▓╤Л╨║╨╗╤О╤З╨░╤В╨╡╨╗╤П┬╗ ╨║╨░╤З╨╡╤Б╤В╨▓╨░ (E10a full-text ╤З╨░╨╜╨║╨░ ╨▓ ╤Н╨╝╨▒╨╡╨┤╨┤╨╕╨╜╨│, e5 `query:`/`passage:`-╨┐╤А╨╡╤Д╨╕╨║╤Б╤Л ╨▓ llama.cpp-╨▓╨╡╤В╨║╨╡ тАФ ONNX/OpenVINO ╤Г╨╢╨╡ ╨╕╨╝╨╡╨╗╨╕ `_ensure_prefix`, E10c ╨┐╤Г╨╗ reranker 30тЖТ50) ╨╜╨╡ ╨┤╨░╨╗╨╕ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨░╨╡╨╝╨╛╨│╨╛ ╤Б╨┤╨▓╨╕╨│╨░. ╨з╨╕╤Б╤В╤Л╨╣ ╨┐╤А╨╛╨│╨╛╨╜ (599 ╤Д╨░╨╣╨╗╨╛╨▓ / 9514 ╤З╨░╨╜╨║╨╛╨▓, 799.9s): fast hit@1=0% hit@5=50%; quality hit@1=20% hit@5=40%; baseline ╨░╨▓╤В╨╛╤А╨░ 0/50% ╨╕ 30/30%. ╨Ф╨╡╨╗╤М╤В╨░ тАФ ╨▓ ╨┐╤А╨╡╨┤╨╡╨╗╨░╤Е ╤И╤Г╨╝╨░ N=10. - **Fix (╨┐╤А╨╡╨┤╨╛╤В╨▓╤А╨░╤Й╨╡╨╜╨╕╨╡):** ╨╕╨╖╨╝╨╡╨╜╤С╨╜╨╜╤Л╨╣ ╨║╨╛╨┤ ╨╛╤В╨║╨░╨╗╨╡╨╜ ╨║ HEAD (╨┐╨╛╨▓╨╡╨┤╨╡╨╜╨╕╨╡ ╨║╨╗╨╕╨╡╨╜╤В╨░ = ╨┐╤А╨╛╨┤); ╨╛╤Б╤В╨░╤В╨╛╨║ тАФ env-╤В╤Г╨╝╨▒╨╗╨╡╤А `MAX_RERANKER_INPUT` ╤Б default=30 (╨╜╨╡╨╣╤В╤А╨░╨╗╨╡╨╜). ╨Я╨╗╨░╤Вo ┬лpure-vector┬╗ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨╡╨╜╨╛ ╨┐╨╛╨▓╤В╨╛╤А╨╜╨╛ (╤Б╤А. Exp-29 ceiling ~0.23). - **╨б╤В╨░╤В╤Г╤Б:** тЭМ REFUTED (╨╖╨░╨║╤А╤Л╤В, ╨╖╨░╨┐╨╕╤Б╨░╨╜ ╨▓ lab exp-43). ╨б╨╗╨╡╨┤╤Г╤О╤Й╨╕╨╣ ╤Е╨╛╨┤ тАФ AST/Graph-hybrid re-ranking, ╨╜╨╡ ╤Н╨╝╨▒╨╡╨┤╨┤╨╕╨╜╨│╨╛╨▓╤Л╨╡ ╤В╨▓╨╕╨║╨╕. + +--- +## 2026-10-24 — E35: MS MARCO MiniLM rerank — out-of-domain, 0.688 → 0.417 — 🔴 rejected + +- **Плечи:** demote / minilm_top10 / minilm_top20 (ONNX int8, 23 MB). +- **Результат:** demote 0.688/0.729, 85 ms; MiniLM top10 0.417/0.646, 723 ms; + top20 0.417/0.542, 1405 ms. +- **Вердикт:** web-passage модель не подходит для кода; нужен code-aware reranker + (Qwen3-Reranker-0.6B) или recall-работа. Лучший результат — E33 demote. +- **Артефакты:** `experiments/e35_small_rerank/`. + +--- +## 2026-10-24 — E34b: сильные graph/PRF boosts — снова ноль — 🟡 open + +- graph ×1.5 / PRF ×1.2 / both: все h1 0.688, h5 0.729, p50 93–94 ms. +- Добавленные кандидаты не gold/дубликаты. Детерминированное расширение пула исчерпано. +- **Артефакты:** `experiments/e34b_boost/`. + +--- +## 2026-10-24 — E34: graph expansion + PRF — нулевой результат — 🟡 open + +- **Плечи:** demote / +PRF / +graph / +graph+PRF на 48 запросах E30. +- **Результат:** все h1 0.688, h5 0.729, MRR 0.705, p50 108–119 ms — идентично. + H1/H2/H3/H4/H6/H7 FAIL; H5 PASS. +- **Вердикт:** PRF/graph-кандидаты (0.8×/0.6× top-1) не входят в top-5; расширение пула + само по себе не двигает выдачу. Нужен ranker или сильный boost. Дальше — E35 cross-encoder. +- **Артефакты:** `experiments/e34_graph_prf/`. + +--- +## 2026-10-24 — E33: demote non-code — +3 hit@1 (0.625 → 0.688), p50 97 ms — 🟢 worth enabling + +- **Панель:** 48 запросов E30; baseline, FTS5 weights, +demote, +prior/exact. +- **Результат:** baseline 0.625/0.708, 155 ms; **demote 0.688/0.729, 97 ms**; + FTS5 weights нейтральны; prior+exact 0.667 (хуже). +- **Решение:** добавить demote `tests/docs/experiments/fixtures ×0.3` в прод под флагом + `MSCODEBASE_DEMOTE_NON_CODE=true`. Caller prior не включать. +- **Артефакты:** `experiments/e33_deterministic/`. + +--- +## 2026-10-24 — E32: code-aware BM25 — h1 не вырос, h5 +1 — 🟡 open + +- **Плечи:** baseline / code_b75 / code_b45 / code_b45_stop на 48 запросах E30. +- **Результат:** baseline h1 0.625/h5 0.729; code_b45_stop h1 0.604/h5 **0.750**; p50 90 ms. +- **Вердикт:** identifier split + field boosts не дают h1; stopwords+b45 дают +1 h5. + Токенизация не узкое место. `MSCODEBASE_CODE_BM25=false` по умолчанию. +- **Следующие:** graph expansion, query expansion/PRF, маленький cross-encoder, semantic anchors. + Артефакты: `experiments/e32_code_bm25/`. + +--- +## 2026-10-24 — E31: BGE-M3 rerank — лучший h1 0.646, но 6.6–19.5 s — 🟡 open + +- **Панель:** 48 запросов E30; no_rerank vs BGE top10/top30/top50 (top50 timeout). +- **Результат:** no_rerank 0.625/0.729, 107 ms; BGE top10 0.646/0.771, 6577 ms; + BGE top30 0.562/0.812, 19521 ms. +- **Вердикт:** BGE top10 даёт +1 h1, но 60× медленнее; 95% (h1/h5) не достигнуто. + BGE-M3 Q4 слишком тяжёл для CPU. Следующий шаг — маленький ONNX cross-encoder + (bge-reranker-base/MiniLM) + code-aware BM25/recall. +- **Артефакты:** `experiments/e31_bge_rerank/`. + +--- +## 2026-10-24 — E30: on-demand rerank не даёт качества на real-панели — 🟡 open + +- **Панель:** 48 реальных запросов (33 E28 + 15 свежих docstring), frozen `d4934cb7…`. +- **Результат:** clean `no_rerank` h1 **0.625**, p50 **109 ms**; Q8 768 h1 0.604 (4.3–5.1 s); + Q4 768 h1 0.562 (1.9–3.9 s); e5 h1 0.354 (0.9–1.1 s). +- **Вердикт:** on-demand rerank не бьёт fused retrieval; ~40× latency без выигрыша. + H7 FAIL (no-rerank на 1 запрос лучше Q8), H1/H3 тоже FAIL. +- **Решение:** держать `MSCODEBASE_ONDEMAND_RERANK` выключенным до панели >100 запросов; + если нужен rerank — Q8 768 (0.604) или Q8 256 (0.583). Артефакты: `experiments/e30_prod_rerank/`. + +--- +## 2026-10-24 — Phase 3: latency/quality gap (on-demand e5) — 🟡 open + +- **Сделано:** SQLite/FTS5 chunk store, миграция 13 645 чанков, поиск без LanceDB, on-demand rerank. +- **Замерено:** SQLite retrieval ~25 ms; e5 llama.cpp embed (11 texts/400 tok) ~0.55 s; + end-to-end p50 ~0.85 s; hit1 3–4/33 на E28-frozen (EXP-28 dense_off+BGE: 12/33). +- **Причина:** e5 on-demand rerank слабее BGE (E25: e5 h1 3/10 vs Q4 5/10) + tokenizer-trim + добавляет HTTP-round-trips. Target <0.6 s end-to-end не достигнут. +- **Варианты:** fast rerank-модель (MiniLM-L6-v2 ~0.49 s) или вернуть BGE для quality-режима; + до решения — статус open, не «зелёно». + +--- +## 2026-10-24 — Audit backlog: ruff 278, docstrings 196, protocol guards 7 — 🟡 tech debt (PR) + +- **ruff** (src/tests/scripts/tools): **278** errors; project-wide `ruff check .` — **1094** + (добавляются experiments/tools-скрипты). Top rules — W293 72, BLE001 63, I001 37, + F541 27, E402 26, F401 22, F841 7, F821 3, invalid-syntax 2; **176 auto-fixable**. +- **invalid-syntax**: `scripts/live_search_audit.py` (Unexpected indentation, l.64) — + скрипт не запускается и не парсится; не импортируется тестами (проверено `git grep`), + поэтому гейты зелёные. Починить в том же lint-PR. +- **flaky**: `tests/test_llama_mutex.py::test_start_sync_spawns_embedder_once_under_concurrency` + — проходит изолированно (1.66s), но падает в полном прогоне (1 failed / 2134 passed). + Класс P-005/P-011: concurrency-тест зависит от общего состояния соседних тестов; нужен + стресс-прогон ×8 до «фикса». +- **docstrings**: `scripts/check_docstrings.py` — **196** публичных функций без docstring (rc=1). +- **protocol guards**: `scripts/audit_protocol_guards.py` — 7 findings; измеренная FP-доля + **62.5% (5 из 8)** → **3 actionable** (в т.ч. `experiments/4A_unit_of_return/frozen/e7_HANDOUT_EN.recovered.md` без required field). +- **План:** отдельный PR `chore(lint): …` + `docs(docstrings): …`; гейты не отключать, + порог не поднимать. До PR — статус tech debt, не «зелёно». +- Проверено 2026-10-24: pytest 2135 passed / 7 skipped; run_all rc=0; third-party blocking=0. + +--- +## 2026-10-24 — Чужой тред dev.to (55KB) дословно лежал в трекнутых файлах публичного MIT-репо — ✅ PURGED + +- **Найдено:** аудит поднабора для XERJ Corpus Hub: 2 трекнутых артефакта (замороженный + тред dev.to, 73 комментария, 18 авторов) + инструментарий его выкачки/заморозки. + Дословный чужой текст в MIT-репо = заражение лицензией (dev.to — CC BY-NC-SA) + + согласие авторов корпуса не получено. +- **Почему R3-сканер прошёл мимо:** advisory-правило R3 смотрело объём/домены; на себе + FP/живость не замерили (P-016-класс: наличие детектора ≠ живость; §19.8). +- **Сделано 2026-10-24 (по команде владельца «вычистить всё связанное»):** + удалены из репо 2 дампа + 4 скрипта выкачки + досье активности (7 tracked файлов), + удалены 3 untracked harness-скрипта, удалён локальный сырой дамп (82 680 B, вне репо). + sha256 каждого — в карантинном манифесте: + `%LOCALAPPDATA%\mscodebase\audit-cache\purged_third_party_2026-10-24\MANIFEST.sha256`. +- **Остаток (честно):** чужие байты остаются в **git-истории**; purge рабочего дерева — + не purge истории. Фильтрация истории (force-push/BFG) не выполнялась без явной команды. +- Статус: ✅ P1 закрыт в рабочем дереве · OPEN_QUESTION: переписывать ли историю. + +--- +## 2026-10-03 — Третье состояние «я не видел» СУЩЕСТВУЕТ; сломана проводка (SUPERSEDES запись от 2026-10-03, перенесена в `KNOWN_ISSUES_2026_10.md`) + +- **Что опровергнуто (Verified 2026-10-03, перевывод из кода, не из реестра):** «в системе + есть два состояния (`PASS`/`FAIL`) и **нет третьего**» и «частичное исключение существует + только для одного гейта — это фрагмент, а не метод». Оба утверждения неверны. +- **Механизм есть и назван:** `tools/verification/gates.py:18` — коды `0` pass / `1` violation / + `2` unusable input; `:35` — `UNKNOWN = "not enough input to judge -> must never read as ALLOW"`; + `:53` G1→`UNKNOWN` на пустой выборке; `:174` G2→`UNKNOWN` на пустом клайме. + `tools/verification/g5_denominator.py:404,407` печатает `POPULATION UNDETERMINABLE` / + `POPULATION EMPTY`. Ещё 4 места печатают `POPULATION EMPTY` отказ не отдавать 0% + (`bootstrap_denominator_manifest.py:19`, `triage_protocol_findings.py:132`, + `e2e_quality_search.py:151`, `claims_audit/denominator_census.py:88`). +- **Четвёртое состояние спроектировано и заморожено:** `experiments/prompt_robustness/frozen/ + HYPOTHESES.md:49` (A4) — код возврата разведён на `0` PASS / `1` FAIL / `2` только POPULATION + ERROR / `3` UNKNOWN. +- **Корень — проводка, а не механизм (знаменатель назван):** `gates.py` не подключён **ни к одному + из 11** гейтов pre-commit; `.githooks/pre-commit:78` возвращает `False` на **любой** ненулевой + код, то есть `rc=2` («unusable input») схлопывается в тот же красный, что и настоящее нарушение. + Третье состояние существует и при этом всё равно читается как «провал». **0 из 11.** +- **Ещё 3 гейта вне цепочки:** `check_docstrings.py`, `check_lsp_health.py`, `check_zero_vectors.py` + существуют и не вызваны из `.githooks/pre-commit`. +- **Что из старой записи остаётся верным:** счётчика наблюдений своего условия нет ни у одного + гейта (0 совпадений по `observ|seen|never_fired` в `scripts/*.py`); пороги гейтов выведены + только на посаженных нарушениях, то есть benign-only. +- **Отдельно (Verified):** `src/mcp/tools/meta_tools.py:332` и `src/mcp/server_tools.py:597` — + две реализации одного вывода счётчиков; в первой `max(calls, 1)` даёт «Blocked 0.0%» при + `checks=0`, во второй явная ветка «данных нет». Починка **1 из 2**; баг присутствует во всех + 4 деревьях (`wt-gate-fix`, `wt-pr-v3`, `mscb-wt-64`, основное). +- **Побочный факт о самом реестре:** `_sync_known_issues` (`src/core/auto_doc_updater.py:372`) + дописывает записи в конец файла и **не проверяет лимит R1** — то есть автосинк сам является + драйвером переполнения. Плюс ~20 строк файла повреждены кодировкой (автосинк писал в cp866). +- **Класс:** P-019 (правило знаем — не всюду) и P-022 (результат зависит от контекста запуска). +- **Статус:** 🔴 Open (P1). Не реализовано: подключение `gates.py` к pre-commit, разведение + `rc=1`/`rc=2` в хуке, правка `meta_tools.py:332`, лимит R1 в автосинке. + + + + + + +## 2026-10-03 — Параллельные сессии без границ: 3 инцидента за один день (Open, P1) +- **Инцидент 1 (каталог-призрак).** `tools/knowledge/` (15 файлов, включая замороженный + тред и referent отрицательного результата D-05) появился в общем дереве, через ~2 минуты + исчез. Сессия объявила «данные потеряны, P1, git не вернёт». **На деле каталог цел** — + он в worktree соседней сессии (`mscb-wt-64`). Признак, который я проглядел: + `CreationTime 03.10 07:20:05` у всех 15 файлов при `LastWriteTime 01.10` = копирование. +- **Инцидент 2 (файл из чужого дерева).** В 08:38 соседняя сессия записала + `tools/verification/verify_public_claims.py` **в моё рабочее дерево** — без предупреждения. + Мой `git status` загрязнён; любой коммит собрал бы чужую работу. +- **Инцидент 3 (ветка-территория).** Та же сессия переключила дерево `mscb-wt-64` + с `feat/protocol-triage-and-t10-guards` на `main`, пока я разбирался, что делать + с неслитой веткой. Мой план P1 устарел в момент составления. +- **Общий корень:** нигде не было записано «одно дерево = одна сессия». Правило отсутствовало → + границ не существовало. Сессии не видели друг друга, но писали в общие файлы. +- **Guard:** `scripts/check_parallel_sessions.py` — 11-й pre-commit. Показывает все чужие + worktree с ветками и незакоммиченными файлами; **BLOCK**, если застейдженный файл изменён + в другом дереве или это реестр (`AGENT_DIARY`/`KNOWN_ISSUES`/`EXPERIMENTS_LOG`/`WISDOM`/`ISSUE`), + который трогает другая сессия; advisory на worktree в `%TEMP%` и на «свои реестры». + Контроль: `--selftest` positive 1/1 + negative 2/2; проверенно реальное срабатывание — + застейдженный файл соседней сессии даёт BLOCK. **Правило в AGENTS.md §7.1b + глобальный §21.** +- **Два бага гейта поймал сам и сам же починил (обязательно к изучению):** + (1) `_norm()` не был применён → гейт принимал **собственное** дерево за чужое и печатал + уверенную неправду; (2) контроль `%TEMP%` был написан от выдуманного пользователя `x`, + а не от реального `$TEMP`, поэтому «падающий» контроль не падал вовсе. **Selleftest, + построенный не на реальной среде, даёт зелёный свет ни о чём** (P-02 в новой формулировке). +- **Статус:** 🔴 Open (P1) — правила и гейт есть, но **согласования владельца не было**: + активная сессия в чужом worktree активна прямо сейчас, а `mscb-wt-64` лежит в `%TEMP%`. + + +## 2026-10-03 — Вендоренный файл Apache-2.0 без NOTICE (Open, документированное отступление) +- **Локация:** `experiments/4A_unit_of_return/frozen/crystal_catalogue_2026-09-26.md` + (5 873 B) — дословная копия `catalogue/README.md` из `Tirthahq/crystal-memory` @3e30ed2. +- **Состояние:** provenance-заголовок есть (URL, дата загрузки, лицензия, SHA), и в нём + **прямо сказано**, что NOTICE не воспроизводится. То есть это **зафиксированное отступление, + а не скрытая утечка** — ровно то, как должно выглядеть. +- **Чего не хватает:** Apache-2.0 §4(c) обязывает воспроизвести NOTICE, **если он есть у + оригинала**. Наличие NOTICE в upstream **не проверено**. В репо нет ни одного файла + третьих сторон (`THIRD_PARTY*`/`NOTICE*`/`licenses/` → 0 вхождений). +- **Guard/фикс:** проверить upstream на NOTICE; при наличии — добавить файл третьих сторон + в корень и дополнить provenance-заголовок. До проверки статус «документированное отступление». +- **Статус:** 🟡 Open, низкий риск (5.9 KB, лицензия разрешает редактирование с указанием правок). + + +## 2026-10-03 — `tools/knowledge/`: корпус не отслеживается git и шарится между двумя worktree (Open, P1) +> ⚠️ **Запись переписана в тот же день.** Первая версия утверждала «каталог исчез, вернуть +> нечем» — это была **неверная интерпретация одного снимка** (см. «Ошибка» ниже). Файлы целы. + +- **Где на самом деле:** `%TEMP%\mscb-wt-64\tools\knowledge\` — + 15 файлов, включая **`repoint_stale_refs.py`** (referent отрицательного результата **D-05** + в реестре паттернов) и второй слепок чужого треда (удалён 2026-10-24 по §20.1; + в рабочем дереве его больше нет). + В моём рабочем дереве они были видны 03.10 07:20 и исчезли ~07:22 — **`CreationTime 07:20:05` + у всех 15 при `LastWriteTime` 01.10 20:25–22:03 = сигнатура КОПИРОВАНИЯ**, а не создания. +- **Ошибка агента (моя, записано как guard):** я увидел, что каталога нет в моём дереве, и + записал «потеря данных, P1, git не вернёт». На деле он **переехал в соседний worktree** + активной сессии. **Вывод «нет в этом дереве» ≠ «нет вообще»**, когда рядом работает + параллельная сессия: единственный корректный тест — спросить соседний worktree, а не + делать вывод по одному снимку `git status`. Это ровно класс «наличие ≠ живость», но + наоборот — **отсутствие ≠ потеря**. +- **Реальный риск (остаётся):** каталог **неотслеживаем в обоих деревьях** (`?? tools/knowledge/`), + поэтому `git log`/`checkout` его не вернёт, а `git clean -fd` в любом из деревьев — **удалит + безвозвратно**. Он существует только на диске Temp. +- **Guard:** research-корпусы — **только в треке** (`experiments/**/frozen/`) с sha256-манифестом. + Плюс CI-guard «untracked-каталог с артефактами исследования дольше N часов» и, отдельно, + **`mscb-wt-64` лежит в `%TEMP%`** — Temp очищается системой; постоянные worktree туда класть нельзя. +- **Дубликат:** было два слепка одного треда (мой в audit-cache и второй в `tools/knowledge/`); + **оба удалены 2026-10-24**, sha256 — в карантинном манифесте. Урок остаётся: перед + заморозкой искать существующую копию, и держать один канон, а не два. +- **Статус:** 🔴 Open (P1, риск потери). Обнаружено и переписано 2026-10-03. + + +## 2026-10-03 — `tools/verification/`: 13 файлов верификации живут на неслитой ветке; `gate` падает на текущей (Open, P1) +- **Root Cause (Verified 2026-10-03, исправлено после первой неверной версии):** файлы + **не удалены и не потеряны**. `tools/verification/gates.py` (25 356 B) плюс ещё 12 файлов + вешают в коммите `9462dc2f` на ветке **`feat/protocol-triage-and-t10-guards`** + (есть и локально, и в `origin`), которая **не является предком HEAD** + (`git merge-base --is-ancestor` → rc=1). Текущая ветка — `chore/bump-urllib3-pyjwt-cve` + @ `d1e77fad`. Удаляющего коммита нет: `git log --diff-filter=D -- tools/verification/gates.py` пуст. +- **Почему выглядело как «исходника нет»:** компиляция `__pycache__/{gates,g5_denominator}.cpython-314.pyc` + от **01.10 23:35** — неотслеживаемый остаток от чекаута той ветки. Именно `.pyc`, а не исходник, + делает тул **видимым, но нерабочим**: MCP-тул `gate` → `rc=2: can't open file 'tools\verification\gates.py'`. +- **Влияние:** на текущей ветке любой вердикт `gate` = **BLOCK, а не ALLOW** (§19.6 — гейт, + который не может ответить, не читается как разрешение). Проверки сессии 2026-10-03 были + ручными и помечены как не-механизированные (`experiments/audit_devto_judgements/HANDOFF.md`). +- **⛔ Не делать руками:** не переписывать `gates.py` с нуля и не вытаскивать из `.pyc`. + Файл цел, лежит в git. +- **Фикс (решение владельца):** merge либо cherry-pick ветки `feat/protocol-triage-and-t10-guards`. +- **⛔ HALT — параллельная сессия:** эта ветка зачекаучена в **другом worktree** + `%TEMP%\mscb-wt-64` @ `74bfa2ad` → там работает другой агент. + Не переключать ветку и не вытягивать файлы из-под него в этом дереве (§3 HALT, + multi-agent-coordination: правка файла, редактируемого параллельно). +- **Guard (после merge):** CI-гейт «MCP-тул зарегистрирован, но его скрипт отсутствует в + рабочем дереве» — иначе класс **«наличие ≠ живость»** (`.pyc` без `.py`, неслитая ветка) + повторится молча. Отдельно: `__pycache__`-ископаемые в трек не коммитить. +- **Статус:** 🔴 Open (P1). Найдено 2026-10-03; root cause уточнён в тот же день + (первая версия записи ошибочно утверждала «исходник никогда не был в треке» — опровергнуто + `git log --all --follow`). + + +## 2026-10-03 — Тихий ноль и «свой контроль» на защите лишь частично (Open) +- **Факт (Verified 2026-10-03, grep, не по памяти):** `sys.exit(2)` — **6 мест из 73 скриптов** + в `scripts/` (`diag_quality_hang.py:261,270`, `revision_gate.py:127`, `run_1L_live_arm.py:313,317`, + `smoke_livesync.py:27`). Знаменатель: `(Get-ChildItem scripts\*.py).Count` = 73. + **Покрытие НЕ доказано** — остальные 67 могут быть защищены иначе; это не измерение доли. +- **Отсутствует вовсе (0 вхождений по репо):** маркер `coverage unknown` (печать того, чего + инструмент не видел) и assertion на совпадение популяций (`assert len(cache) == len(documents)`). +- **Отсутствует:** claims ledger (число привязано к конфигу, при которой измерено) — + `claims ledger`, `claims_ledger`, `measured under`, `live config`, `config_stale`, `stale claim` → 0. +- **Источник класса:** Tom Jones, «Implementation is where judgements go to become invisible» + (dev.to `4p1h`, 27.09.2026) + комментарии. Первоисточник удалён 2026-10-24 (§20.1); + sha256 сохранён в карантинном манифесте. +- **Статус:** 🔴 Open. Не баг, а измеренный пробел покрытия. Эксперименты E-1/E-4/E-5 и + Red Team — в `HANDOFF.md` §3/§4. diff --git a/install.py b/install.py index ed43025e..98fe3d71 100644 --- a/install.py +++ b/install.py @@ -437,8 +437,9 @@ def step_copy(lines, lang): ".env", "llama_msvc", # управляется step_llama "llama_vulkan", # управляется step_llama - "models", # управляется step_gguf - } + "models", # управляется step_gguf + "experiments", # эксперименты не нужны в расширении + } items = [i for i in PROJECT_ROOT.iterdir() if i.name not in skip] copied = skipped = deleted = 0 diff --git a/scripts/migrate_chunks_to_sqlite.py b/scripts/migrate_chunks_to_sqlite.py new file mode 100644 index 00000000..8b338c20 --- /dev/null +++ b/scripts/migrate_chunks_to_sqlite.py @@ -0,0 +1,56 @@ +"""Phase 3 migration: LanceDB chunks -> SQLite/FTS5 text store. + +Usage: + python scripts/migrate_chunks_to_sqlite.py --project D:\\Project\\MSCodeBase + +The source LanceDB path is resolved via artifact_paths.get_db_path(project). +The destination is /.mscodebase/chunks.db (override: --sqlite). +No vectors are copied; search recomputes them on demand (E25). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + parser = argparse.ArgumentParser() + parser.add_argument("--project", required=True) + parser.add_argument("--lancedb", default="") + parser.add_argument("--sqlite", default="") + args = parser.parse_args() + + project = Path(args.project).resolve() + if not project.exists(): + print(f"FATAL: project not found: {project}", file=sys.stderr) + return 2 + + from src.core.artifact_paths import get_db_path + from src.core.indexing.chunk_store import get_chunks_db_path, migrate_from_lancedb + + lancedb_path = Path(args.lancedb) if args.lancedb else get_db_path(project) + sqlite_path = Path(args.sqlite) if args.sqlite else get_chunks_db_path(project) + + if not lancedb_path.exists(): + print(f"FATAL: LanceDB not found: {lancedb_path}", file=sys.stderr) + return 2 + + result = migrate_from_lancedb(lancedb_path, sqlite_path) + if result.get("migrated", 0) <= 0: + print("FATAL: zero chunks migrated (empty source?)", file=sys.stderr) + return 2 + + print( + f"OK migrated={result['migrated']} files={result['files']} " + f"sqlite={sqlite_path}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rebuild_graph.py b/scripts/rebuild_graph.py new file mode 100644 index 00000000..b87b286a --- /dev/null +++ b/scripts/rebuild_graph.py @@ -0,0 +1,67 @@ +"""Rebuild the PropertyGraph without triggering a vector reindex. + +Why: `MSCODEBASE_DENSE_OFF=true` makes `Indexer.index_project()` return early, +so the graph (and all graph/LSP tools) stays empty even though chunks are fresh. +This script indexes symbols/calls directly into a temp DB and atomically replaces +the production graph. Run it while the MCP server is stopped, or restart after. + +Usage: + python scripts/rebuild_graph.py [project_root] +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ROOT = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +def main() -> int: + from src.core.artifact_paths import get_graph_db_path + from src.core.graph import PropertyGraph + from src.core.indexing.parser import CodeParser + from src.core.search.graph_adapter import SymbolIndexAdapter + + target = get_graph_db_path(ROOT) + target.parent.mkdir(parents=True, exist_ok=True) + temp = target.with_name(target.name + ".new") + if temp.exists(): + temp.unlink() + + t0 = time.perf_counter() + graph = PropertyGraph(temp) + adapter = SymbolIndexAdapter(graph, mode=SymbolIndexAdapter.MODE_PURE) + adapter.index_project(str(ROOT), CodeParser()) + + conn_nodes = graph.count_nodes() + conn_edges = graph.count_edges() + graph.close() + print(f"built temp graph: nodes={conn_nodes} edges={conn_edges} " + f"elapsed={time.perf_counter() - t0:.1f}s") + + try: + os.replace(temp, target) + except OSError as exc: + print(f"WARN: could not replace {target} (server running?): {exc}") + print(f"temp graph kept at {temp}; stop MCP and rename it manually.") + return 2 + + print(f"replaced {target}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception: + import traceback + + traceback.print_exc() + raise SystemExit(1) diff --git a/scripts/reindex_text_only.py b/scripts/reindex_text_only.py new file mode 100644 index 00000000..3fe669d7 --- /dev/null +++ b/scripts/reindex_text_only.py @@ -0,0 +1,183 @@ +"""Phase 3 text-only full reindex: rebuild SQLite chunks + AST graph, no vectors. + +Use this after wiping the vector/chunk/graph stores. It walks the project, +parses every file through IndexParser (same AST/symbol pipeline as the indexer), +writes text chunks to SQLite/FTS5 and updates PropertyGraph. + +No embeddings are computed: vectors are recreated on demand at query time. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import sys +import threading +import time +from pathlib import Path + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + parser = argparse.ArgumentParser() + parser.add_argument("--project", required=True) + parser.add_argument("--max-files", type=int, default=0) + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + + project = Path(args.project).resolve() + if not project.exists(): + print(f"FATAL: project not found: {project}", file=sys.stderr) + return 2 + + # Imports are local so `--help` works without a configured project. + from src.core.artifact_paths import get_graph_db_path + from src.core.graph import PropertyGraph + from src.core.indexing.chunk_store import SQLiteChunkStore, get_chunks_db_path + from src.core.indexing.db_writer import LanceDBWriter + from src.core.indexing.file_guard import FileGuard + from src.core.indexing.index_parser import IndexParser + from src.core.indexing.index_pipeline import IndexPipeline + from src.core.indexing.parser import CodeParser + from src.core.search.graph_adapter import SymbolIndexAdapter + from src.sources.local_fs.windows import SafePathManager + + print(f"[text-reindex] project={project}") + t0 = time.perf_counter() + + cfg_dir = project / ".mscodebase" + cfg_dir.mkdir(parents=True, exist_ok=True) + + guard = FileGuard(project) + code_parser = CodeParser() + path_manager = SafePathManager(cfg_dir) + index_parser = IndexParser( + parser=code_parser, + path_manager=path_manager, + project_path=project, + ) + + graph_db = get_graph_db_path(project) + graph_db.parent.mkdir(parents=True, exist_ok=True) + pg = PropertyGraph(graph_db) + symbol_index = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + + store = SQLiteChunkStore(get_chunks_db_path(project)) + writer = LanceDBWriter( + table=None, + table_write_lock=threading.RLock(), + index_lock=threading.Lock(), + embedder=None, + chunk_store=store, + ) + pipeline = IndexPipeline( + embedder=None, + parser=code_parser, + index_parser=index_parser, + symbol_index=symbol_index, + symbol_index_lock=threading.Lock(), + project_path=project, + table=None, + ) + + files_seen = 0 + files_indexed = 0 + chunks_written = 0 + errors = 0 + seen_paths: set[str] = set() + pruned = 0 + try: + for root, dirs, files in os.walk(str(project)): + dirs[:] = [d for d in dirs if not guard.should_skip_dir(d)] + for name in files: + if args.max_files and files_seen >= args.max_files: + break + fp = Path(root) / name + if guard.should_skip_file(fp): + continue + files_seen += 1 + rel = fp.relative_to(project).as_posix() + seen_paths.add(rel) + try: + content = fp.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if not content.strip(): + continue + rel = fp.relative_to(project).as_posix() + current_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() + try: + parsed = pipeline.process_file( + rel_path_str=rel, + full_path=fp, + content=content, + current_hash=current_hash, + ) + except Exception as e: + errors += 1 + if not args.quiet: + print(f"[text-reindex] parse error {rel}: {e}") + continue + if not parsed: + continue + records = writer.write_text_records(parsed) if parsed.get("skip_vectors") else [] + if records: + files_indexed += 1 + chunks_written += len(records) + if not args.quiet and files_indexed and files_indexed % 100 == 0: + print( + f"[text-reindex] files={files_indexed} chunks={chunks_written} " + f"seen={files_seen}" + ) + else: + continue + break + + # Persist graph metadata (head) if available. + try: + from src.core.intelligence.verify_on_read import resolve_head_dirty + + fresh = resolve_head_dirty(project) + if fresh is not None: + head, _dirty = fresh + pg.set_meta("build_head", head) + except Exception: + pass + + # Prune stale rows (removed files + now-excluded experiments/**). + # Skip for partial runs (--max-files), otherwise we would delete files + # that were simply not visited. + if not args.max_files: + existing = { + str(r.get("file_path") or "").replace("\\", "/") + for r in store.all_records() + } + stale = sorted(existing - seen_paths) + if stale: + store.delete_files(stale) + pruned = len(stale) + + elapsed = time.perf_counter() - t0 + print( + f"[text-reindex] done files={files_indexed} chunks={chunks_written} " + f"seen={files_seen} errors={errors} pruned={pruned} sqlite_rows={store.count()} " + f"graph_nodes={pg.count_nodes()} graph_edges={pg.count_edges()} " + f"elapsed={elapsed:.1f}s" + ) + if chunks_written <= 0: + print("FATAL: zero chunks written (empty project?)", file=sys.stderr) + return 2 + return 0 + finally: + store.close() + try: + pg.close() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_e2e.py b/scripts/smoke_e2e.py index 519f09db..d1102120 100644 --- a/scripts/smoke_e2e.py +++ b/scripts/smoke_e2e.py @@ -102,7 +102,7 @@ def _health(url: str, timeout: float = 3.0) -> bool: def check_services() -> dict: """Проверяет health всех трёх сервисов.""" services = { - "embedder": ("http://127.0.0.1:8080/health", 384), # e5-small dim + "embedder": ("http://127.0.0.1:8080/health", 768), # embeddinggemma-2 dim "reranker": ("http://127.0.0.1:8081/health", None), "onnx": ("http://127.0.0.1:9876/health", None), } @@ -127,8 +127,8 @@ def check_embed() -> tuple[bool, str]: data = r.json() vec = data["data"][0]["embedding"] dim = len(vec) - ok = dim == 384 - return ok, f"dim={dim} (e5-small: 384) {'✅' if ok else '❌'}" + ok = dim == 768 + return ok, f"dim={dim} (embeddinggemma-2: 768) {'✅' if ok else '❌'}" except Exception as e: # noqa: BLE001 — диагностика return False, f"embed error: {e}" diff --git a/src/config/settings.py b/src/config/settings.py index cb8aae67..eab464ee 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -176,24 +176,110 @@ class SearchConfig: # On-demand rerank (E25, Фаза 2 миграции): скорить top-N fused-кандидатов # живыми эмбеддингами в момент запроса вместо хранимых векторов + BGE. - # Off по умолчанию — поведение поиска неизменно без флага. + # Фаза 3: включён по умолчанию (owner decision 2026-10-24). # field(default_factory) — чтобы reload_config() перечитывал env. ondemand_rerank: bool = field( default_factory=lambda: os.getenv( - "MSCODEBASE_ONDEMAND_RERANK", "false" + "MSCODEBASE_ONDEMAND_RERANK", "true" ).lower() == "true" ) ondemand_rerank_top_n: int = field( default_factory=lambda: int(os.getenv("MSCODEBASE_ONDEMAND_RERANK_TOP_N", "10")) ) + # E25 protocol used a 400-token trim before embedding candidates; the + # char cap is a tokenizer-free approximation (~4 chars/token). + ondemand_rerank_max_chars: int = field( + default_factory=lambda: int( + os.getenv("MSCODEBASE_ONDEMAND_RERANK_MAX_CHARS", "1600") + ) + ) # Dense-off (E28, Фаза 3): true → НЕ выполнять dense-тир (эмбеддинг запроса + # векторный поиск в LanceDB). Кандидаты остаются из BM25/FTS5/graph. - # Off по умолчанию — поведение поиска неизменно без флага. + # Фаза 3: включён по умолчанию (owner decision 2026-10-24). dense_off: bool = field( default_factory=lambda: os.getenv( - "MSCODEBASE_DENSE_OFF", "false" + "MSCODEBASE_DENSE_OFF", "true" + ).lower() + == "true" + ) + + # E32: code-aware BM25 (identifier splitting, field boosts, proper BM25). + # Off by default; E32 measures it before enabling. + code_bm25: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_CODE_BM25", "false" + ).lower() + == "true" + ) + code_bm25_k1: float = field( + default_factory=lambda: float(os.getenv("MSCODEBASE_CODE_BM25_K1", "1.2")) + ) + code_bm25_b: float = field( + default_factory=lambda: float(os.getenv("MSCODEBASE_CODE_BM25_B", "0.45")) + ) + code_bm25_stopwords: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_CODE_BM25_STOPWORDS", "true" + ).lower() + == "true" + ) + + # E40: Russian → English code-term synonym expansion. + # Off by default; E40 measures it on the real panel. + ru_synonyms: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_RU_SYNONYMS", "false" + ).lower() + == "true" + ) + + # E33: demote tests/docs/experiments/fixtures in the fused pool. + # Off by default; E33 measured +3 hit@1 on 48 real queries. + demote_non_code: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_DEMOTE_NON_CODE", "false" + ).lower() + == "true" + ) + # E42: for docs-intent queries, do not demote docs/ (only tests/experiments). + demote_intent_aware: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_DEMOTE_INTENT_AWARE", "false" + ).lower() + == "true" + ) + + # E36-audit: P3 module-head anchors only pay off with a reranker; without + # one they add ~96 ms and do not change top-5. Off by default. + p3_anchor: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_P3_ANCHOR", "false" + ).lower() + == "true" + ) + + # E57: cheap automatic docs routing. Compare the best docs-only BM25 score + # with the best code score; docs if margin > threshold. No LLM. + docs_margin: float = field( + default_factory=lambda: float(os.getenv("MSCODEBASE_DOCS_MARGIN", "4.0")) + ) + + # Phase 3: text chunk store in SQLite/FTS5 (no stored vectors). + # Default ON; set MSCODEBASE_SQLITE_CHUNKS=false to fall back to LanceDB. + sqlite_chunks: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_SQLITE_CHUNKS", "true" + ).lower() + == "true" + ) + + # E26: prepend FILE/SYMBOLS/DOC to chunk text (Phase 1 of the migration). + # Default ON per owner decision; requires reindex for existing chunks. + augment_chunks: bool = field( + default_factory=lambda: os.getenv( + "MSCODEBASE_AUGMENT_CHUNKS", "true" ).lower() == "true" ) diff --git a/src/core/indexing/chunk_augment.py b/src/core/indexing/chunk_augment.py index a9554c05..0db531ec 100644 --- a/src/core/indexing/chunk_augment.py +++ b/src/core/indexing/chunk_augment.py @@ -21,8 +21,8 @@ def augment_enabled() -> bool: - """E26 augmentation flag. Default OFF (no behavior change).""" - return os.getenv("MSCODEBASE_AUGMENT_CHUNKS", "").lower() in ("1", "true", "yes") + """E26 augmentation flag. Phase 3 default ON (owner decision 2026-10-24).""" + return os.getenv("MSCODEBASE_AUGMENT_CHUNKS", "true").lower() in ("1", "true", "yes") def module_doc(body: str) -> str: diff --git a/src/core/indexing/chunk_store.py b/src/core/indexing/chunk_store.py new file mode 100644 index 00000000..2d6a4c31 --- /dev/null +++ b/src/core/indexing/chunk_store.py @@ -0,0 +1,596 @@ +"""SQLite/FTS5 chunk store (Phase 3 of the approved stack migration). + +Stores chunk text + metadata WITHOUT vectors. Candidate retrieval is done by +SQLite FTS5 (BM25 rank), vectors are recomputed on demand only for the top-N +candidates (see src/core/search/ondemand_rerank.py). + +The store is intentionally independent from LanceDB: once populated, the +search path can run with the LanceDB vector table unavailable. +""" + +from __future__ import annotations + +import json +import logging +import re +import sqlite3 +import threading +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +__all__ = [ + "SQLiteChunkStore", + "get_chunks_db_path", + "migrate_from_lancedb", +] + +logger = logging.getLogger("mscodebase_server.chunk_store") + +_TOKEN_RE = re.compile(r"[A-Za-zА-Яа-я0-9_]+") + + +def get_chunks_db_path(project_path: Path | str) -> Path: + """Path to the lightweight chunk DB. + + Default: ``/.mscodebase/chunks.db`` (owner-approved location). + ``MSCODEBASE_CHUNKS_DB`` overrides it (tests / alternate installations). + """ + import os + + env = os.getenv("MSCODEBASE_CHUNKS_DB", "").strip() + if env: + return Path(env) + return Path(project_path).resolve() / ".mscodebase" / "chunks.db" + + +def _fts_query(query: str) -> str: + """Build a safe FTS5 MATCH expression from a free-form query.""" + terms = _TOKEN_RE.findall(query or "") + if not terms: + return "" + return " OR ".join(f'"{t}"' for t in terms) + + +class SQLiteChunkStore: + """Persistent text chunk store backed by SQLite + FTS5.""" + + def __init__(self, db_path: Path | str): + self.db_path = Path(db_path) + self._lock = threading.RLock() + self._conn: Optional[sqlite3.Connection] = None + # Optional FTS5 column weights: (text, text_full, symbol_name, docstring, file_path) + self.field_weights: Optional[tuple[float, ...]] = None + + # ── Lifecycle ───────────────────────────────────────────── + + def _ensure_conn(self) -> sqlite3.Connection: + if self._conn is not None: + return self._conn + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA busy_timeout=5000") + self._conn = conn + self._create_schema() + return conn + + def _create_schema(self) -> None: + conn = self._conn + assert conn is not None + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chunks ( + rowid INTEGER PRIMARY KEY AUTOINCREMENT, + chunk_id TEXT UNIQUE NOT NULL, + file_path TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + text TEXT NOT NULL, + text_full TEXT, + symbol_name TEXT, + symbol_kind TEXT, + docstring TEXT, + layer TEXT, + start_line INTEGER, + end_line INTEGER, + indexed_at TEXT, + chunk_hash TEXT, + file_hash TEXT, + file_mtime_ns INTEGER, + file_size INTEGER, + metadata_json TEXT + ) + """ + ) + conn.execute( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + text, text_full, symbol_name, docstring, file_path, + content='chunks', content_rowid='rowid', + tokenize='porter unicode61' + ) + """ + ) + conn.executescript( + """ + CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, text, text_full, symbol_name, docstring, file_path) + VALUES (new.rowid, new.text, new.text_full, new.symbol_name, new.docstring, new.file_path); + END; + CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, text, text_full, symbol_name, docstring, file_path) + VALUES('delete', old.rowid, old.text, old.text_full, old.symbol_name, old.docstring, old.file_path); + END; + CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, text, text_full, symbol_name, docstring, file_path) + VALUES('delete', old.rowid, old.text, old.text_full, old.symbol_name, old.docstring, old.file_path); + INSERT INTO chunks_fts(rowid, text, text_full, symbol_name, docstring, file_path) + VALUES (new.rowid, new.text, new.text_full, new.symbol_name, new.docstring, new.file_path); + END; + """ + ) + conn.commit() + + def close(self) -> None: + with self._lock: + if self._conn is not None: + try: + self._conn.close() + except (sqlite3.Error, OSError): + pass + self._conn = None + + # ── Writes ──────────────────────────────────────────────── + + def upsert_records(self, records: Iterable[Dict[str, Any]]) -> int: + """Insert/replace chunk records; stale rows of touched files are removed.""" + rows = list(records) + if not rows: + return 0 + + files = {str(r.get("file_path", "")) for r in rows if r.get("file_path")} + with self._lock: + conn = self._ensure_conn() + cur = conn.cursor() + try: + for fp in files: + cur.execute("DELETE FROM chunks WHERE file_path = ?", (fp,)) + for r in rows: + fp = str(r.get("file_path", "")) + ci = int(r.get("chunk_index", 0) or 0) + chunk_id = str(r.get("chunk_id") or r.get("id") or f"{fp}:{ci}") + meta = {k: v for k, v in r.items() if k not in { + "text", "text_full", "symbol_name", "symbol_kind", + "docstring", "file_path", "chunk_index", "layer", + "start_line", "end_line", "indexed_at", "chunk_hash", + "file_hash", "file_mtime_ns", "file_size", "chunk_id", "id", + }} + cur.execute( + """ + INSERT OR REPLACE INTO chunks ( + chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, chunk_hash, + file_hash, file_mtime_ns, file_size, metadata_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + chunk_id, + fp, + ci, + str(r.get("text", "") or ""), + str(r.get("text_full", r.get("text", "")) or ""), + str(r.get("symbol_name", "") or ""), + str(r.get("symbol_kind", r.get("symbol_type", "")) or ""), + str(r.get("docstring", "") or ""), + str(r.get("layer", "") or ""), + int(r.get("start_line", 0) or 0), + int(r.get("end_line", 0) or 0), + str(r.get("indexed_at", "") or ""), + str(r.get("chunk_hash", "") or ""), + str(r.get("file_hash", "") or ""), + int(r.get("file_mtime_ns", 0) or 0), + int(r.get("file_size", 0) or 0), + json.dumps(meta, ensure_ascii=False, default=str), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + return len(rows) + + def delete_file(self, file_path: str) -> int: + with self._lock: + conn = self._ensure_conn() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM chunks WHERE file_path = ?", (file_path,)) + n = int(cur.fetchone()[0]) + if n: + cur.execute("DELETE FROM chunks WHERE file_path = ?", (file_path,)) + conn.commit() + return n + + def delete_files(self, file_paths: Iterable[str]) -> int: + """Delete all chunks for a set of files (FTS triggers stay in sync).""" + files = [str(fp) for fp in file_paths if fp] + if not files: + return 0 + with self._lock: + conn = self._ensure_conn() + cur = conn.cursor() + for i in range(0, len(files), 500): + batch = files[i : i + 500] + placeholders = ",".join("?" for _ in batch) + cur.execute( + f"DELETE FROM chunks WHERE file_path IN ({placeholders})", batch + ) + conn.commit() + return len(files) + + # ── Reads ───────────────────────────────────────────────── + + def count(self) -> int: + with self._lock: + conn = self._ensure_conn() + return int(conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]) + + def file_hash(self, file_path: str) -> Optional[str]: + """Stored content hash for a file (None if the file is absent).""" + with self._lock: + conn = self._ensure_conn() + row = conn.execute( + "SELECT file_hash FROM chunks WHERE file_path = ? LIMIT 1", + (file_path,), + ).fetchone() + return str(row[0]) if row and row[0] else None + + def files_count(self) -> int: + with self._lock: + conn = self._ensure_conn() + return int( + conn.execute("SELECT COUNT(DISTINCT file_path) FROM chunks").fetchone()[0] + ) + + def search(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """FTS5 candidate retrieval, ranked by BM25. + + Returns the same shape the engine's RRF/rerank steps expect: + ``{"text": ..., "metadata": {...}, "final_score": ...}``. + """ + fts_q = _fts_query(query) + if not fts_q: + return [] + + with self._lock: + conn = self._ensure_conn() + try: + weights = self.field_weights + if weights: + rank_expr = "bm25(chunks_fts, ?, ?, ?, ?, ?)" + params = (*[float(w) for w in weights[:5]], fts_q, int(limit)) + else: + rank_expr = "bm25(chunks_fts)" + params = (fts_q, int(limit)) + rows = conn.execute( + f""" + SELECT c.chunk_id, c.file_path, c.chunk_index, c.text, + c.text_full, c.symbol_name, c.symbol_kind, + c.docstring, c.layer, c.start_line, c.end_line, + c.indexed_at, c.file_hash, c.chunk_hash, + c.metadata_json, {rank_expr} AS rank + FROM chunks_fts + JOIN chunks c ON c.rowid = chunks_fts.rowid + WHERE chunks_fts MATCH ? + ORDER BY rank + LIMIT ? + """, + params, + ).fetchall() + except sqlite3.Error as e: + logger.debug(f"FTS5 query failed ({e}); LIKE fallback") + like = f"%{query}%" + rows = conn.execute( + """ + SELECT chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, file_hash, + chunk_hash, metadata_json, 0.0 AS rank + FROM chunks + WHERE text LIKE ? OR text_full LIKE ? OR symbol_name LIKE ? + LIMIT ? + """, + (like, like, like, int(limit)), + ).fetchall() + + out: List[Dict[str, Any]] = [] + for idx, r in enumerate(rows): + rank = float(r[15] or 0.0) + meta = { + "file": r[1], + "chunk_index": int(r[2] or 0), + "symbol_name": r[5] or "", + "symbol_kind": r[6] or "", + "layer": r[8] or "", + "start_line": int(r[9] or 0), + "end_line": int(r[10] or 0), + "indexed_at": r[11] or "", + "file_hash": r[12] or "", + "chunk_hash": r[13] or "", + "source": "sqlite_fts5", + } + try: + extra = json.loads(r[14] or "{}") + if isinstance(extra, dict): + meta.update({k: v for k, v in extra.items() if k not in meta}) + except (json.JSONDecodeError, TypeError): + pass + out.append( + { + "text": r[4] or r[3] or "", + "metadata": meta, + "final_score": -rank if rank else 1.0 / (idx + 1), + "source": "sqlite_fts5", + } + ) + return out + + def get_symbol_chunk( + self, file_path: str, symbol_name: str + ) -> Optional[Dict[str, Any]]: + """Return the first chunk for a (file, symbol) pair, or None.""" + if not file_path or not symbol_name: + return None + with self._lock: + conn = self._ensure_conn() + row = conn.execute( + """ + SELECT chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, file_hash, + chunk_hash, metadata_json, 0.0 AS rank + FROM chunks + WHERE file_path = ? AND symbol_name = ? + ORDER BY chunk_index + LIMIT 1 + """, + (file_path, symbol_name), + ).fetchone() + if not row: + return None + meta = { + "file": row[1], + "chunk_index": int(row[2] or 0), + "symbol_name": row[5] or "", + "symbol_kind": row[6] or "", + "layer": row[8] or "", + "start_line": int(row[9] or 0), + "end_line": int(row[10] or 0), + "indexed_at": row[11] or "", + "file_hash": row[12] or "", + "chunk_hash": row[13] or "", + "source": "sqlite_graph_expand", + } + try: + extra = json.loads(row[14] or "{}") + if isinstance(extra, dict): + meta.update({k: v for k, v in extra.items() if k not in meta}) + except (json.JSONDecodeError, TypeError): + pass + return { + "text": row[4] or row[3] or "", + "metadata": meta, + "final_score": 0.0, + "source": "sqlite_graph_expand", + } + + def get_file_head(self, file_path: str) -> Optional[Dict[str, Any]]: + """Return the chunk_index=0 chunk for a file (module head), or None.""" + if not file_path: + return None + with self._lock: + conn = self._ensure_conn() + row = conn.execute( + """ + SELECT chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, file_hash, + chunk_hash, metadata_json, 0.0 AS rank + FROM chunks + WHERE file_path = ? + ORDER BY chunk_index + LIMIT 1 + """, + (file_path,), + ).fetchone() + if not row: + return None + meta = { + "file": row[1], + "chunk_index": int(row[2] or 0), + "symbol_name": row[5] or "", + "symbol_kind": row[6] or "", + "layer": row[8] or "", + "start_line": int(row[9] or 0), + "end_line": int(row[10] or 0), + "indexed_at": row[11] or "", + "file_hash": row[12] or "", + "chunk_hash": row[13] or "", + "source": "sqlite_module_head", + } + try: + extra = json.loads(row[14] or "{}") + if isinstance(extra, dict): + meta.update({k: v for k, v in extra.items() if k not in meta}) + except (json.JSONDecodeError, TypeError): + pass + return { + "text": row[4] or row[3] or "", + "metadata": meta, + "final_score": 0.0, + "source": "sqlite_module_head", + } + + def get_chunk_near_line( + self, file_path: str, line: int + ) -> Optional[Dict[str, Any]]: + """Return the nearest chunk at/just before a source line, or None.""" + if not file_path: + return None + with self._lock: + conn = self._ensure_conn() + row = conn.execute( + """ + SELECT chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, file_hash, + chunk_hash, metadata_json, 0.0 AS rank + FROM chunks + WHERE file_path = ? AND start_line <= ? + ORDER BY start_line DESC + LIMIT 1 + """, + (file_path, int(line)), + ).fetchone() + if row is None: + row = conn.execute( + """ + SELECT chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, file_hash, + chunk_hash, metadata_json, 0.0 AS rank + FROM chunks + WHERE file_path = ? + ORDER BY start_line ASC + LIMIT 1 + """, + (file_path,), + ).fetchone() + if not row: + return None + meta = { + "file": row[1], + "chunk_index": int(row[2] or 0), + "symbol_name": row[5] or "", + "symbol_kind": row[6] or "", + "layer": row[8] or "", + "start_line": int(row[9] or 0), + "end_line": int(row[10] or 0), + "indexed_at": row[11] or "", + "file_hash": row[12] or "", + "chunk_hash": row[13] or "", + "source": "sqlite_graph_line", + } + try: + extra = json.loads(row[14] or "{}") + if isinstance(extra, dict): + meta.update({k: v for k, v in extra.items() if k not in meta}) + except (json.JSONDecodeError, TypeError): + pass + return { + "text": row[4] or row[3] or "", + "metadata": meta, + "final_score": 0.0, + "source": "sqlite_graph_line", + } + + def all_records(self) -> List[Dict[str, Any]]: + """All chunks (for BM25 compatibility / diagnostics).""" + with self._lock: + conn = self._ensure_conn() + rows = conn.execute( + """ + SELECT chunk_id, file_path, chunk_index, text, text_full, + symbol_name, symbol_kind, docstring, layer, + start_line, end_line, indexed_at, chunk_hash, + file_hash, file_mtime_ns, file_size, metadata_json + FROM chunks + """ + ).fetchall() + out: List[Dict[str, Any]] = [] + for r in rows: + out.append( + { + "chunk_id": r[0], + "file_path": r[1], + "chunk_index": int(r[2] or 0), + "text": r[3] or "", + "text_full": r[4] or "", + "symbol_name": r[5] or "", + "symbol_kind": r[6] or "", + "docstring": r[7] or "", + "layer": r[8] or "", + "start_line": int(r[9] or 0), + "end_line": int(r[10] or 0), + "indexed_at": r[11] or "", + "chunk_hash": r[12] or "", + "file_hash": r[13] or "", + "file_mtime_ns": int(r[14] or 0), + "file_size": int(r[15] or 0), + } + ) + return out + + +def migrate_from_lancedb( + lancedb_path: Path | str, + sqlite_path: Path | str, + table_name: str = "codebase_chunks", + batch_size: int = 500, +) -> Dict[str, Any]: + """Dump LanceDB chunks into the SQLite store (text + metadata, no vectors).""" + import lancedb + + db = lancedb.connect(str(lancedb_path)) + table = db.open_table(table_name) + store = SQLiteChunkStore(sqlite_path) + try: + df = table.to_pandas() + if df is None or df.empty: + return {"migrated": 0, "files": 0} + + records: List[Dict[str, Any]] = [] + for _, row in df.iterrows(): + text = str(row.get("text", "") or "") + text_full = str(row.get("text_full", "") or text) + file_path = str(row.get("file_path", "") or "") + chunk_index = int(row.get("chunk_index", 0) or 0) + symbol_name = "" + symbol_kind = str(row.get("symbol_type", "") or "") + m = re.search(r"(?:class|def|async\s+def)\s+(\w+)", text) + if m: + symbol_name = m.group(1) + _meta = {} + for key in ("summary", "module_name", "hierarchy_level", "is_public", + "parent_id", "callees", "health_score", "health_band", + "source"): + if key in row: + _meta[key] = row.get(key) + records.append( + { + "chunk_id": str(row.get("id") or f"{file_path}:{chunk_index}"), + "file_path": file_path, + "chunk_index": chunk_index, + "text": text, + "text_full": text_full, + "symbol_name": symbol_name, + "symbol_kind": symbol_kind, + "docstring": "", + "layer": str(row.get("layer", "") or ""), + "start_line": int(row.get("start_line", 0) or 0), + "end_line": int(row.get("end_line", 0) or 0), + "indexed_at": str(row.get("indexed_at", "") or ""), + "chunk_hash": str(row.get("chunk_hash", "") or ""), + "file_hash": str(row.get("file_hash", "") or ""), + "file_mtime_ns": int(row.get("file_mtime_ns", 0) or 0), + "file_size": int(row.get("file_size", 0) or 0), + **_meta, + } + ) + # One upsert for the whole snapshot: file-level delete must see ALL + # chunks of a file at once, otherwise a later batch deletes chunks + # inserted by an earlier batch of the same file. + store.upsert_records(records) + return {"migrated": len(records), "files": store.files_count()} + finally: + store.close() diff --git a/src/core/indexing/db_writer.py b/src/core/indexing/db_writer.py index dce393a6..f4ce18a6 100644 --- a/src/core/indexing/db_writer.py +++ b/src/core/indexing/db_writer.py @@ -20,7 +20,7 @@ class LanceDBWriter: """Управляет записью чанков в LanceDB с атомарностью и кэш-синхронизацией.""" - def __init__(self, table, table_write_lock, index_lock, embedder, db_manager=None): + def __init__(self, table, table_write_lock, index_lock, embedder, db_manager=None, chunk_store=None): self.table = table self._table_write_lock = table_write_lock self._index_lock = index_lock @@ -28,6 +28,8 @@ def __init__(self, table, table_write_lock, index_lock, embedder, db_manager=Non # LanceDBManager (владелец db_path/db) — для физического пересоздания # таблицы (INC-6C62). None → fallback на drop+create (старое поведение). self.db_manager = db_manager + # Phase 3: SQLite/FTS5 text store (dual-write while LanceDB is alive). + self.chunk_store = chunk_store def write_records( self, @@ -171,8 +173,50 @@ def write_records( logger.warning(f"Restore to version {prev_version} failed: {restore_err}") raise + if self.chunk_store is not None: + try: + self.chunk_store.upsert_records(data_records) + except Exception as cs_err: + logger.warning(f"SQLite chunk store write failed for {rel_path_str}: {cs_err}") + return data_records + def write_text_records(self, parsed: Dict[str, Any]) -> list: + """Phase 3: write text chunks to the SQLite store only (no vectors).""" + if self.chunk_store is None: + return [] + from src.core.relpath import normalize_rel_path + + rel_path_str = normalize_rel_path(parsed["rel_path"]) + texts = parsed.get("chunk_texts", []) or [] + fulls = parsed.get("chunk_texts_full", []) or [] + metas = parsed.get("chunk_metadatas", []) or [] + hashes = parsed.get("chunk_hashes", []) or [] + records = [] + for i, text in enumerate(texts): + meta = metas[i] if i < len(metas) else {} + records.append({ + "chunk_id": f"{rel_path_str}:{i}", + "file_path": rel_path_str, + "chunk_index": i, + "text": text, + "text_full": fulls[i] if i < len(fulls) and fulls[i] else text, + "symbol_name": meta.get("symbol_name", ""), + "symbol_kind": meta.get("symbol_type", ""), + "docstring": meta.get("docstring", ""), + "layer": meta.get("layer", ""), + "start_line": meta.get("start_line", 0), + "end_line": meta.get("end_line", 0), + "indexed_at": datetime.now().isoformat(), + "chunk_hash": hashes[i] if i < len(hashes) else "", + "file_hash": parsed.get("current_hash", ""), + "file_mtime_ns": parsed.get("file_mtime_ns", 0), + "file_size": parsed.get("file_size", 0), + }) + if records: + self.chunk_store.upsert_records(records) + return records + def _safe_recreate_table(self): """Fallback: пересоздать таблицу при потере. @@ -370,4 +414,12 @@ def bulk_write(self, all_prepared: list) -> int: logger.warning(f"Restore to version {prev_version} failed: {restore_err}") raise + if self.chunk_store is not None: + try: + for ep in paths_to_delete: + self.chunk_store.delete_file(ep) + self.chunk_store.upsert_records(all_records) + except Exception as cs_err: + logger.warning(f"SQLite chunk store bulk write failed: {cs_err}") + return len(all_records) diff --git a/src/core/indexing/index_pipeline.py b/src/core/indexing/index_pipeline.py index a365b972..bdd761db 100644 --- a/src/core/indexing/index_pipeline.py +++ b/src/core/indexing/index_pipeline.py @@ -137,6 +137,28 @@ def process_file( if not chunk_texts: return None + # E36-audit: chunk metadata carried no symbol_name, so FTS5/BM25/graph + # hydration all lost the symbol signal. Map AST symbols to chunk line + # ranges before the text store write. + _metas = parsed.get("chunk_metadatas", []) + if symbols and _metas: + _ordered = sorted( + (s for s in symbols if s.get("name")), + key=lambda s: int(s.get("line", 0) or 0), + ) + for _meta in _metas: + _line = int(_meta.get("start_line", 0) or 0) + _best = None + for _s in _ordered: + if int(_s.get("line", 0) or 0) <= _line: + _best = _s + else: + break + if _best: + _full = str(_best.get("name", "")) + _meta.setdefault("symbol_name", _full.split(".")[-1]) + _meta.setdefault("symbol_qualified", _full) + # ─── Chunk-level content-addressed cache ─────────── # Вычисляем chunk_hash для каждого чанка. Если хэш уже есть в БД, # переиспользуем сохранённый вектор (skip embed_batch). @@ -149,6 +171,31 @@ def process_file( h = "ch:" + hashlib.sha256(t.encode("utf-8")).hexdigest()[:32] chunk_hashes.append(h) + # ─── Phase 3: vector-free indexing (SQLite/FTS5 store) ──────── + # When dense is off, do NOT compute or store embeddings: the chunk + # text goes to SQLite and vectors are recomputed on demand at query + # time (E25). This is what blocks the heavy vector reindex. + try: + from src.config.settings import get_config as _get_cfg + + if getattr(_get_cfg().search, "dense_off", False): + return { + "rel_path": rel_path_str, + "current_hash": current_hash, + "chunk_texts": parsed["chunk_texts"], + "chunk_texts_full": parsed.get("chunk_texts_full", []), + "chunk_metadatas": parsed.get("chunk_metadatas", []), + "chunk_hashes": chunk_hashes, + "health": parsed.get("health", {"score": 0.0, "band": ""}), + "source": source, + "embeddings": [], + "skip_vectors": True, + "file_mtime_ns": parsed.get("file_mtime_ns", 0), + "file_size": parsed.get("file_size", 0), + } + except Exception as _cfg_err: + logger.debug(f"dense_off check failed: {_cfg_err}") + # Загружаем известные векторы из БД (если table доступен) known_vectors: dict = {} if self._table is not None: diff --git a/src/core/indexing/indexer.py b/src/core/indexing/indexer.py index 8b35042b..55821bd2 100644 --- a/src/core/indexing/indexer.py +++ b/src/core/indexing/indexer.py @@ -153,12 +153,37 @@ def __init__( # ─── LanceDBWriter from src.core.indexing.db_writer import LanceDBWriter + + # Phase 3: SQLite/FTS5 text store (dual-write while LanceDB is alive). + self._chunk_store = None + try: + from src.config.settings import get_config as _get_cfg + + _search_cfg = _get_cfg().search + if getattr(_search_cfg, "sqlite_chunks", False) or getattr( + _search_cfg, "dense_off", False + ): + from src.core.indexing.chunk_store import ( + SQLiteChunkStore, + get_chunks_db_path, + ) + + self._chunk_store = SQLiteChunkStore( + get_chunks_db_path(self.project_path) + ) + logger.info( + f"Phase 3 SQLite chunk store: {get_chunks_db_path(self.project_path)}" + ) + except Exception as _cs_err: + logger.warning(f"SQLite chunk store init failed: {_cs_err}") + self._db_writer = LanceDBWriter( table=self.table, table_write_lock=self._table_write_lock, index_lock=self._index_lock, embedder=self.embedder, db_manager=self.db_manager, # INC-6C62: физическое пересоздание таблицы + chunk_store=self._chunk_store, ) # Регистрируем callback для синхронизации таблицы при пересоздании self._db_writer.set_on_recreate_callback(self._sync_table_ref) @@ -336,6 +361,22 @@ def _write_file_records( logger.info(f"Записано в БД: {rel_path_str} ({len(records)} чанков)") return True + def _write_text_file_records(self, parsed: Dict) -> bool: + """Phase 3: write text-only chunks to the SQLite store (no LanceDB).""" + rel_path_str = parsed["rel_path"] + records = self._db_writer.write_text_records(parsed) + if not records: + return False + with self._index_lock: + self._cached_total_chunks += len(records) + self._cached_unique_files.add(rel_path_str) + try: + self._index_guard.save_symbol_index(self._symbol_index) + except Exception: + pass + logger.info(f"Записано в SQLite: {rel_path_str} ({len(records)} чанков)") + return True + def _parse_file_only( self, full_path: Path, @@ -362,6 +403,13 @@ def _parse_file_only( existing_hash = None if known_hashes is not None: existing_hash = known_hashes.get(rel_path_str) + elif self._chunk_store is not None: + try: + existing_hash = self._chunk_store.file_hash(rel_path_str) + except Exception as _cs_err: + logger.debug( + f"_parse_file_only: SQLite hash check failed: {_cs_err}" + ) else: try: if self.table is not None: @@ -500,18 +548,24 @@ def _index_single_file( # P1-3 audit: чтение self.table под _table_write_lock, чтобы не # поймать stale reference при параллельном reset_connection(). existing_hash = None - try: - with self._table_write_lock: - existing_df = ( - self.table.search() - .where(f"file_path = '{escaped_path}'", prefilter=True) - .limit(1) - .to_pandas() - ) - if not existing_df.empty: - existing_hash = str(existing_df["file_hash"].iloc[0]) - except Exception as _cache_err: - logger.debug(f"Chunk cache check failed for {rel_path_str}: {_cache_err}") + if self._chunk_store is not None: + try: + existing_hash = self._chunk_store.file_hash(rel_path_str) + except Exception as _cs_err: + logger.debug(f"SQLite hash check failed for {rel_path_str}: {_cs_err}") + else: + try: + with self._table_write_lock: + existing_df = ( + self.table.search() + .where(f"file_path = '{escaped_path}'", prefilter=True) + .limit(1) + .to_pandas() + ) + if not existing_df.empty: + existing_hash = str(existing_df["file_hash"].iloc[0]) + except Exception as _cache_err: + logger.debug(f"Chunk cache check failed for {rel_path_str}: {_cache_err}") if existing_hash == current_hash: return False # Файл не изменился, пропускаем @@ -536,6 +590,14 @@ def _index_single_file( if parsed is None: return False + # Phase 3: vector-free path — write text chunks to SQLite only. + if parsed.get("skip_vectors"): + if not self._write_text_file_records(parsed): + return False + if self.searcher is not None and hasattr(self.searcher, "invalidate_cache"): + self.searcher.invalidate_cache() + return True + # ─── Delete old + Write new ────────────────────── # Сначала удаляем старые записи, потом добавляем новые. # Такой порядок гарантирует, что файл не пропадёт из индекса @@ -793,6 +855,21 @@ def index_project( phase_callback: Optional[Callable] = None, ) -> int: """Полная индексация проекта (делегировано IndexProjectRunner).""" + # Phase 3 kill-switch: full vector reindex is blocked (dense_off=true). + # Text-only incremental indexing (notify_change / _index_single_file) + # keeps the SQLite store fresh without computing vectors. + try: + from src.config.settings import get_config as _get_cfg + + if getattr(_get_cfg().search, "dense_off", False): + logger.warning( + "Phase 3: full vector reindex blocked " + "(MSCODEBASE_DENSE_OFF=true). Incremental text-only indexing stays active." + ) + return 0 + except Exception as _cfg_err: + logger.debug(f"dense_off guard failed: {_cfg_err}") + res = self._project_runner.run( project_path=project_path, progress_callback=progress_callback, diff --git a/src/core/search/bm25.py b/src/core/search/bm25.py index 37760001..c3260a37 100644 --- a/src/core/search/bm25.py +++ b/src/core/search/bm25.py @@ -40,6 +40,9 @@ def reindex(self): self._bm25 = None self._bm25_ids = [] self._bm25_df = None + self._bm25_idf = {} + self._bm25_doc_len = {} + self._bm25_avgdl = 0.0 logger.debug("Индекс BM25 сброшен для реиндексации") def _reset_bm25(self) -> None: @@ -52,6 +55,9 @@ def _reset_bm25(self) -> None: self._bm25 = None self._bm25_ids = [] self._bm25_df = None + self._bm25_idf = {} + self._bm25_doc_len = {} + self._bm25_avgdl = 0.0 logger.debug("BM25 сброшен после meta-patch") # ── Index building ──────────────────────────────────────── @@ -71,6 +77,17 @@ def _build_bm25_index(self) -> None: # Double-check после захвата блокировки if self._bm25 is not None: return + # Phase 3: BM25 source is the SQLite chunk store, not LanceDB. + if getattr(self, "_chunk_store", None) is not None: + try: + if self._chunk_store.count() > 0: + self._build_bm25_from_records(self._chunk_store.all_records()) + return + except Exception as e: + logger.warning(f"BM25: SQLite chunk store unavailable: {e}") + self._bm25 = {} + return + if self.indexer.table is None: self._bm25 = {} return @@ -137,6 +154,88 @@ def _build_bm25_index(self) -> None: self._bm25_ids = [] self._bm25_df = None + def _build_bm25_from_records(self, records: List[dict]) -> None: + """Build BM25 from SQLite chunk records (Phase 3, no LanceDB).""" + if getattr(self, "_code_bm25", False): + self._build_code_bm25_from_records(records) + return + self._bm25_ids = [] + self._bm25_df = None + self._bm25_rows = {} + if not records: + self._bm25 = {} + return + doc_count = len(records) + term_doc_freq: Dict[str, int] = {} + term_doc_scores: Dict[str, Dict[str, float]] = {} + for row in records: + doc_id = f"{row['file_path']}:{row['chunk_index']}" + self._bm25_rows[doc_id] = row + tokens = _tokenize(str(row.get("text", "")), self._tokenizer_re) + term_tf: Dict[str, float] = {} + for token in tokens: + term_tf[token] = term_tf.get(token, 0) + 1 + self._bm25_ids.append(doc_id) + term_doc_scores[doc_id] = term_tf + for token in term_tf: + term_doc_freq[token] = term_doc_freq.get(token, 0) + 1 + self._bm25 = {} + for doc_id, tf_dict in term_doc_scores.items(): + self._bm25[doc_id] = {} + for term, tf in tf_dict.items(): + df = term_doc_freq.get(term, 0) + idf = math.log((doc_count - df + 0.5) / (df + 0.5) + 1) + self._bm25[doc_id][term] = tf * idf + logger.debug(f"📊 BM25 индекс построен из SQLite: {len(self._bm25)} документов") + + def _build_code_bm25_from_records(self, records: List[dict]) -> None: + """E32: code-aware BM25 (identifier split, field boosts, k1/b).""" + from .code_tokenizer import tokenize_code + + self._bm25_ids = [] + self._bm25_df = None + self._bm25_rows = {} + self._bm25_idf = {} + self._bm25_doc_len = {} + self._bm25_avgdl = 0.0 + if not records: + self._bm25 = {} + return + + stop = bool(getattr(self, "_code_bm25_stopwords", True)) + term_doc_freq: Dict[str, int] = {} + term_tf: Dict[str, Dict[str, int]] = {} + total_len = 0 + for row in records: + doc_id = f"{row['file_path']}:{row['chunk_index']}" + self._bm25_rows[doc_id] = row + name = tokenize_code(str(row.get("symbol_name", "")), stopwords=stop) + path = tokenize_code(str(row.get("file_path", "")), stopwords=stop) + body = tokenize_code(str(row.get("text", "")), stopwords=stop) + # Field boosts: name x3, path x2, body x1 (ripvec/pi-lens style). + tokens = name * 3 + path * 2 + body + tf: Dict[str, int] = {} + for tok in tokens: + tf[tok] = tf.get(tok, 0) + 1 + term_tf[doc_id] = tf + self._bm25_ids.append(doc_id) + self._bm25_doc_len[doc_id] = len(tokens) + total_len += len(tokens) + for tok in tf: + term_doc_freq[tok] = term_doc_freq.get(tok, 0) + 1 + + n = len(records) + self._bm25_avgdl = (total_len / n) if n else 0.0 + for term, df in term_doc_freq.items(): + self._bm25_idf[term] = math.log( + 1.0 + (n - df + 0.5) / (df + 0.5) + ) + self._bm25 = term_tf + logger.debug( + f"📊 code BM25: {n} docs, avgdl={self._bm25_avgdl:.1f}, " + f"k1={self._code_bm25_k1}, b={self._code_bm25_b}" + ) + # ── Incremental update ──────────────────────────────────── def incremental_update_bm25(self, new_chunks: List[dict]) -> None: @@ -219,13 +318,32 @@ def _bm25_search(self, query: str, limit: int = 5) -> List[dict]: if not self._bm25: return [] - query_tokens = _tokenize(query, self._tokenizer_re) scores: Dict[str, float] = {} - - for doc_id in self._bm25_ids: - scores[doc_id] = 0.0 - for token in query_tokens: - scores[doc_id] += self._bm25[doc_id].get(token, 0.0) + if getattr(self, "_code_bm25", False): + from .code_tokenizer import tokenize_code + + query_tokens = tokenize_code( + query, stopwords=bool(getattr(self, "_code_bm25_stopwords", True)) + ) + k1 = float(getattr(self, "_code_bm25_k1", 1.2)) + b = float(getattr(self, "_code_bm25_b", 0.45)) + avg = float(getattr(self, "_bm25_avgdl", 0.0) or 1.0) + for doc_id in self._bm25_ids: + scores[doc_id] = 0.0 + dl = float(getattr(self, "_bm25_doc_len", {}).get(doc_id, 0)) + norm = k1 * (1.0 - b + b * dl / avg) + for token in query_tokens: + tf = self._bm25.get(doc_id, {}).get(token, 0) + if not tf: + continue + idf = getattr(self, "_bm25_idf", {}).get(token, 0.0) + scores[doc_id] += idf * (tf * (k1 + 1.0)) / (tf + norm) + else: + query_tokens = _tokenize(query, self._tokenizer_re) + for doc_id in self._bm25_ids: + scores[doc_id] = 0.0 + for token in query_tokens: + scores[doc_id] += self._bm25[doc_id].get(token, 0.0) # Сортируем по убыванию скора top_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)[:limit] @@ -233,6 +351,27 @@ def _bm25_search(self, query: str, limit: int = 5) -> List[dict]: # Формируем результаты (используем кэшированный DataFrame) results = [] try: + if self._bm25_df is None: + rows_by_id = getattr(self, "_bm25_rows", None) or {} + for doc_id in top_ids: + if scores[doc_id] <= 0: + continue + row = rows_by_id.get(doc_id) + if not row: + continue + results.append( + { + "text": row.get("text", ""), + "text_full": row.get("text_full", row.get("text", "")), + "metadata": { + "file": row.get("file_path", ""), + "chunk_index": row.get("chunk_index", 0), + "indexed_at": row.get("indexed_at", ""), + "layer": row.get("layer", ""), + }, + } + ) + return results df = self._bm25_df if df is None or df.empty: return results diff --git a/src/core/search/code_tokenizer.py b/src/core/search/code_tokenizer.py new file mode 100644 index 00000000..ecc858f0 --- /dev/null +++ b/src/core/search/code_tokenizer.py @@ -0,0 +1,138 @@ +"""Code-aware tokenization for BM25 (E32). + +Splits identifiers across camelCase / PascalCase / snake_case / kebab-case / +digit boundaries, keeps the raw identifier as a token (exact-match signal), and +optionally filters a small language-agnostic stopword set. + +References (see experiments/e32_code_bm25/RESEARCH.md): +- ripvec-core `split_code_identifier` + Tantivy code analyzer +- pi-lens `splitIdentifier` / word-index +- trusty-search-core three-pass tokenizer +- Scout `tokenize_code` +""" + +from __future__ import annotations + +import re + +__all__ = [ + "split_identifier", + "tokenize_code", + "CODE_STOPWORDS", + "LANGUAGE_KEYWORDS", +] + +_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[0-9]+") + +# Small, language-agnostic stopword set. Deliberately excludes meaningful code +# verbs (get/set/add/update) that are often part of an identifier. +CODE_STOPWORDS = frozenset( + """ + the and or of to in is for on with as by at from that this it an be are + was were will can if else while return class def function const let var + async await pub impl use mod import public private protected static void + int float double string bool boolean true false null none nil self super + try catch except finally raise throw throws package interface struct enum + type fn func method property namespace using include require new delete + break continue switch case default do elif then end begin where select + """.split() +) + +# Common reserved words per language. Used only for stopword filtering; the +# tokenizer itself is language-agnostic. +LANGUAGE_KEYWORDS = { + "python": "False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield self".split(), + "javascript": "await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return static super switch this throw try typeof var void while with yield async of get set".split(), + "typescript": "abstract any as async await boolean break case catch class const constructor continue declare default delete do else enum export extends false finally for from function get if implements import in instanceof interface let module namespace never new null number object of package private protected public readonly require return set static string super switch symbol this throw true try type typeof undefined var void while with yield".split(), + "rust": "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while".split(), + "go": "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var true false nil".split(), + "java": "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null".split(), + "csharp": "abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while async await var".split(), + "ruby": "BEGIN END alias and begin break case class def defined? do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield require include extend".split(), + "php": "abstract and array as break callable case catch class clone const continue declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile eval exit extends final finally fn for foreach function global goto if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public readonly require require_once return static switch throw trait try unset use var while xor yield".split(), + "kotlin": "as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when where while by catch constructor delegate dynamic field file finally get import init param property receiver set setparam".split(), + "swift": "associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private protocol public rethrows static struct subscript typealias var break case continue default defer do else fallthrough for guard if in repeat return switch where while as catch false is nil super self Self throw throws true try".split(), + "c": "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while".split(), + "cpp": "alignas alignof and and_eq asm auto bitand bitor bool break case catch char char8_t char16_t char32_t class compl concept const consteval constexpr constinit const_cast continue co_await co_return co_yield decltype default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept not not_eq nullptr operator or or_eq private protected public register reinterpret_cast requires return short signed sizeof static static_assert static_cast struct switch template this thread_local throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while xor xor_eq".split(), + "scala": "abstract case catch class def do else extends false final finally for forSome if implicit import lazy match new null object override package private protected return sealed super this throw trait try true type val var while with yield".split(), + "dart": "abstract as assert async await break case catch class const continue covariant default deferred do dynamic else enum export extends extension external factory false final finally for Function get hide if implements import in interface is late library mixin new null on operator part required rethrow return set show static super switch sync this throw true try typedef var void while with yield".split(), +} + + +def split_identifier(name: str) -> list[str]: + """Split one identifier into lowercased sub-words. + + ``parseJsonConfig`` -> ``["parse", "json", "config"]`` + ``MAX_RETRY_2`` -> ``["max", "retry", "2"]`` + ``HTML5Parser`` -> ``["html5", "parser"]`` + """ + if not name: + return [] + + parts: list[str] = [] + for segment in re.split(r"[_\-\s]+", name): + if not segment: + continue + chars = list(segment) + n = len(chars) + start = 0 + i = 0 + while i < n: + if i > start: + prev = chars[i - 1] + cur = chars[i] + lower_to_upper = (prev.islower() or prev.isdigit()) and cur.isupper() + upper_run_to_lower = ( + i >= 2 + and prev.isupper() + and cur.islower() + and chars[i - 2].isupper() + ) + digit_boundary = prev.isdigit() != cur.isdigit() + if lower_to_upper or digit_boundary: + parts.append("".join(chars[start:i]).lower()) + start = i + elif upper_run_to_lower: + parts.append("".join(chars[start : i - 1]).lower()) + start = i - 1 + i += 1 + if start < n: + parts.append("".join(chars[start:n]).lower()) + return [p for p in parts if p] + + +def tokenize_code( + text: str, + *, + language: str | None = None, + stopwords: bool = True, + keep_raw: bool = True, +) -> list[str]: + """Tokenize code for BM25. + + Emits the raw lowercased identifier first (exact-match signal), then its + sub-words. Deduplicates while preserving order. + """ + if not text: + return [] + + stop = set(CODE_STOPWORDS) + if language and language in LANGUAGE_KEYWORDS: + stop.update(w.lower() for w in LANGUAGE_KEYWORDS[language]) + if not stopwords: + stop = set() + + out: list[str] = [] + seen: set[str] = set() + for raw in _IDENT_RE.findall(text): + low = raw.lower() + if keep_raw: + if len(low) >= 2 and low not in stop and low not in seen: + seen.add(low) + out.append(low) + for part in split_identifier(raw): + if len(part) < 2 or part in stop or part in seen: + continue + seen.add(part) + out.append(part) + return out diff --git a/src/core/search/engine.py b/src/core/search/engine.py index 0d0f9018..023e64a3 100644 --- a/src/core/search/engine.py +++ b/src/core/search/engine.py @@ -73,6 +73,15 @@ def _cache_key(*parts: str) -> str: # (аудит Bot_snow BS-2/BS-4: точные хиты вытеснялись случайными чанками). _IDENTIFIER_QUERY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{1,63}$") +# E33: non-code directories demoted in the fused pool (tests/docs/etc.). +_NON_CODE_PREFIXES = ( + "tests/", + "test/", + "docs/", + "experiments/", + "fixtures/", +) + # Вариант A (2026-08-17): doc-чанки не бустуются точным именем символа. # KNOWN_ISSUES/docs/adr цитируют имена кода ("class PropagationEngine") и раньше # получали тот же boost ×100, что и исходник — доки вытесняли сам код из топа @@ -421,6 +430,26 @@ def __init__(self, indexer, embedder): self._fts5 = None # FTS5IndexManager (lazy) self._fts5_lock = threading.Lock() self._bm25_df: Any = None + self._bm25_rows = None + self._bm25_idf: Dict[str, float] = {} + self._bm25_doc_len: Dict[str, int] = {} + self._bm25_avgdl: float = 0.0 + _search_cfg = get_config().search + self._code_bm25 = bool(getattr(_search_cfg, "code_bm25", False)) + self._code_bm25_k1 = float(getattr(_search_cfg, "code_bm25_k1", 1.2)) + self._code_bm25_b = float(getattr(_search_cfg, "code_bm25_b", 0.45)) + self._code_bm25_stopwords = bool( + getattr(_search_cfg, "code_bm25_stopwords", True) + ) + self._demote_non_code = bool( + getattr(_search_cfg, "demote_non_code", False) + ) + self._demote_intent_aware = bool( + getattr(_search_cfg, "demote_intent_aware", False) + ) + self._p3_anchor = bool(getattr(_search_cfg, "p3_anchor", False)) + self._docs_margin = float(getattr(_search_cfg, "docs_margin", 4.0)) + self._ru_synonyms = bool(getattr(_search_cfg, "ru_synonyms", False)) self._tokenizer_re = re.compile(r"\W+") self._reranker = SearchResultReranker( bm25_weight=get_config().search.bm25_weight, @@ -445,11 +474,32 @@ def __init__(self, indexer, embedder): self._ondemand_rerank_top_n = int( getattr(get_config().search, "ondemand_rerank_top_n", 10) or 10 ) + self._ondemand_rerank_max_chars = int( + getattr(get_config().search, "ondemand_rerank_max_chars", 1600) or 1600 + ) # Dense-off (E28, Фаза 3): MSCODEBASE_DENSE_OFF=true → dense-тир выключен, # кандидаты только из BM25/FTS5/graph. Off по умолчанию. self._dense_off = bool( getattr(get_config().search, "dense_off", False) ) + # Phase 3: SQLite/FTS5 chunk store (text only, no stored vectors). + # When enabled, candidate retrieval bypasses LanceDB entirely. + self._sqlite_chunks = bool( + getattr(get_config().search, "sqlite_chunks", False) + ) + self._chunk_store = None + if self._sqlite_chunks or self._dense_off: + try: + from src.core.indexing.chunk_store import ( + SQLiteChunkStore, + get_chunks_db_path, + ) + + _project = getattr(self.indexer, "project_path", None) + if _project is not None: + self._chunk_store = SQLiteChunkStore(get_chunks_db_path(_project)) + except Exception as _cs_err: + logger.warning(f"SQLite chunk store init failed: {_cs_err}") self._multi_reranker: Optional[MultiProviderReranker] = None self._multi_reranker_initialized: bool = False self._multi_reranker_lock: Optional[asyncio.Lock] = None # lazy: создаётся при первом async-вызове (привязка к event loop) @@ -487,6 +537,11 @@ async def close(self) -> None: logger.debug(f"Close MultiProviderReranker: {e}") finally: self._multi_reranker = None + if getattr(self, "_chunk_store", None) is not None: + try: + self._chunk_store.close() + except Exception: + pass self._multi_reranker_initialized = False # Закрываем async LanceDB соединение Indexer-а if self.indexer is not None and hasattr(self.indexer, "close_async"): @@ -854,12 +909,13 @@ async def _anchor_module_head_chunks_async( """ if limit <= 0: return pool + df = None try: self._build_bm25_index() df = self._bm25_df - if df is None or df.empty: - return pool except Exception: # noqa: BLE001 — degraded BM25 never breaks search + df = None + if df is None and self._chunk_store is None: return pool have = set() for r in pool: @@ -878,10 +934,25 @@ async def _anchor_module_head_chunks_async( if not _is_anchor_eligible(r): continue try: - match = df[(df["file_path"] == fname) & (df["chunk_index"] == 0)] - if match.empty: + row = None + if df is not None: + match = df[(df["file_path"] == fname) & (df["chunk_index"] == 0)] + if not match.empty: + row = match.iloc[0] + if row is None and self._chunk_store is not None: + chunk = self._chunk_store.get_file_head(fname) + if chunk: + hmeta = chunk.get("metadata") or {} + row = { + "file_path": fname, + "chunk_index": hmeta.get("chunk_index", 0), + "text": chunk.get("text", ""), + "text_full": chunk.get("text_full", chunk.get("text", "")), + "indexed_at": hmeta.get("indexed_at", ""), + "layer": hmeta.get("layer", ""), + } + if row is None: continue - row = match.iloc[0] except Exception: # noqa: BLE001 — one bad file never breaks search continue key = f"{row['file_path']}:{row['chunk_index']}" @@ -906,6 +977,70 @@ async def _anchor_module_head_chunks_async( logger.debug(f"[P3-anchor] +{added} module-head chunks") return pool + async def _sqlite_hybrid_search_async( + self, + query: str, + limit: int, + layer: Optional[str] = None, + tracer: Optional[SearchTracer] = None, + ) -> List[dict]: + """Phase 3 candidate retrieval: SQLite FTS5 + on-demand rerank. + + No LanceDB table access: candidates come from the persistent SQLite + chunk store, vectors are recomputed only for the top-N pool. + """ + raw_limit = min(max(int(limit) * 4, 20), 50) + candidates = await asyncio.to_thread( + self._chunk_store.search, query, raw_limit + ) + if layer: + candidates = [ + r + for r in candidates + if (r.get("metadata") or {}).get("layer") == layer + ] + + # Deterministic graph bucket (PropertyGraph is a separate SQLite DB). + _symbolish = bool(re.search(r"[_\.::/]", query)) or _IDENTIFIER_QUERY_RE.match( + query.strip() + ) + if _symbolish: + try: + graph_results = self._graph_stage(query, raw_limit, layer=layer) + except Exception: + graph_results = [] + if graph_results: + seen = { + f"{r.get('metadata', {}).get('file', '')}:" + f"{r.get('metadata', {}).get('chunk_index', '')}" + for r in candidates + } + for gr in graph_results: + meta = gr.get("metadata", {}) + key = f"{meta.get('file', '')}:{meta.get('chunk_index', '')}" + if key not in seen: + seen.add(key) + candidates.append(gr) + + if not candidates: + self._last_rerank_timing = {} + return [] + + # On-demand rerank: embed only top-N live (E25), no stored vectors. + if self._ondemand_rerank: + final_results = await self._apply_ondemand_reranker_async( + query, candidates, limit + ) + else: + self._last_rerank_timing = {"mode": "sqlite_fts5", "ms": 0.0} + final_results = candidates[: max(int(limit), 0)] + + final_results = _boost_exact_name_matches(final_results, query) + final_results = _dedupe_by_symbol(final_results) + if tracer: + tracer.record_final(final_results) + return final_results[: max(int(limit), 0)] + async def hybrid_search_async( self, query: str, @@ -956,6 +1091,22 @@ async def hybrid_search_async( if not query or not query.strip(): return [] + # ── Phase 3: SQLite/FTS5 path (no LanceDB vector table) ───────────── + # Candidate retrieval is done by SQLite FTS5; vectors are computed + # on demand only for the top-N candidates (E25). This path never + # touches self.indexer.table. + if ( + self._chunk_store is not None + and getattr(self.indexer, "table", None) is None + ): + try: + if self._chunk_store.count() > 0: + return await self._sqlite_hybrid_search_async( + query, limit, layer=layer, tracer=tracer + ) + except Exception as _sq_err: + logger.warning(f"SQLite chunk store path failed: {_sq_err}") + # ── Symbol Graph Path (Вариант А, Step 2): deterministic short-circuit ── # Для идентификатор-запросов граф даёт точную локацию за ~6.8ms и полностью # заменяет векторный поиск (~3600ms). NL-запросы → graph [], идём вниз. @@ -975,6 +1126,12 @@ async def hybrid_search_async( # Query Expansion: генерируем варианты запроса if expand: query_variants = _expand_query(query, max_expansions=3) + if self._ru_synonyms: + from .synonyms_ru import expand_ru_variants + + query_variants = expand_ru_variants( + query, query_variants, max_expansions=4 + ) else: query_variants = [query] @@ -1132,6 +1289,25 @@ async def hybrid_search_async( _boosts[key] = 1.0 tracer.record_co_change(rrf_results, _boosts) + # E33: deterministic non-code demotion in the fused pool (before cut). + if self._demote_non_code: + _prefixes = _NON_CODE_PREFIXES + if self._demote_intent_aware: + _d_intent = ( + auto_detect_intent(query) + if intent_hint == "auto" + else intent_hint + ) + if _d_intent == "docs": + _prefixes = tuple( + p for p in _NON_CODE_PREFIXES if p != "docs/" + ) + for r in rrf_results: + meta = r.get("metadata") or {} + f = str(meta.get("file", "") or "").replace("\\", "/") + if f.startswith(_prefixes): + r["final_score"] = (r.get("final_score", 0.0) or 0.0) * 0.3 + # Сортируем и обрезаем (чистый Python, на 30 элементах — микросекунды) rrf_results.sort(key=lambda x: x.get("final_score", 0.0), reverse=True) pre_rerank_results = rrf_results[:limit] @@ -1164,9 +1340,10 @@ async def hybrid_search_async( # experiments/reranker_p3/), but RRF surfaces code chunks only — # the head of each candidate file is anchored so it reaches the # reranker; O1/doc-guard/caps conventions inherited from P2. - pre_rerank_results = await self._anchor_module_head_chunks_async( - pre_rerank_results, query, limit - ) + if self._p3_anchor: + pre_rerank_results = await self._anchor_module_head_chunks_async( + pre_rerank_results, query, limit + ) # === O1 (2026-09-28): rare-identifier boost at PRE-RERANK pool (D3) === # Runs here — after sort+cut/MMR, before the reranker — never post-cut: @@ -1324,6 +1501,84 @@ async def ask_async(self, query: str, limit: int = 5) -> str: logger.error(f"Ошибка в ask_async: {e}") return _("❌ Ошибка генерации ответа: {error}", error=str(e)) + def prefers_docs(self, query: str) -> bool: + """Cheap docs/code routing by BM25 score margin (no LLM). + + E57: docs top score minus code top score > 4.0 gave 0 code false + positives and +10 p.p. hit@1 (same-basename gold) on the 48-query panel. + """ + store = getattr(self, "_chunk_store", None) + if store is None or not (query or "").strip(): + return False + try: + raw = store.search(query, limit=1000) + except Exception as exc: + logger.debug(f"prefers_docs failed: {exc}") + return False + docs_max = 0.0 + code_max = 0.0 + code_prefixes = ("src/", "tools/", "scripts/", "tests/") + for r in raw: + meta = r.get("metadata") or {} + fp = str(meta.get("file") or "").replace("\\", "/") + score = float(r.get("final_score") or 0.0) + if fp.startswith("docs/"): + docs_max = max(docs_max, score) + elif fp.startswith(code_prefixes): + code_max = max(code_max, score) + return (docs_max - code_max) > self._docs_margin + + def search_docs(self, query: str, limit: int = 5) -> List[Dict[str, Any]]: + """Docs-only FTS5 channel for explicit ``intent="docs"``. + + E56: docs are absent from the hybrid candidate pool; a dedicated + ``docs/**`` FTS query is cheap (~50 ms) and needs no LLM/router. + Returns results in BM25 order, one chunk per file. + """ + store = getattr(self, "_chunk_store", None) + if store is None or not (query or "").strip(): + return [] + try: + raw = store.search(query, limit=max(limit * 8, 40)) + except Exception as exc: + logger.debug(f"search_docs failed: {exc}") + return [] + # Collapse language variants of the same doc (docs/en/X, docs/ru/X, ...) + # so one document does not occupy 3 of 5 slots. Prefer the query language. + langs = {"en", "ru", "zh", "de", "es", "fr", "ja", "ko", "pt", "it"} + prefer = "ru" if re.search(r"[А-Яа-яЁё]", query or "") else "en" + + def _key(fp: str) -> str: + parts = fp.split("/") + if len(parts) >= 3 and parts[0] == "docs" and parts[1] in langs: + return "docs/__lang__/" + "/".join(parts[2:]) + return fp + + order: List[str] = [] + chosen: Dict[str, Dict[str, Any]] = {} + chosen_lang: Dict[str, str] = {} + for r in raw: + meta = r.get("metadata") or {} + fp = str(meta.get("file") or "").replace("\\", "/") + if not fp.startswith("docs/"): + continue + key = _key(fp) + parts = fp.split("/") + lang = parts[1] if len(parts) >= 3 and parts[0] == "docs" else "" + item = dict(r) + item["source"] = "docs_fts" + if key not in chosen: + chosen[key] = item + chosen_lang[key] = lang + order.append(key) + elif lang == prefer and chosen_lang[key] != prefer: + chosen[key] = item + chosen_lang[key] = lang + if len(order) >= limit * 2: + break + out = [chosen[k] for k in order[:limit]] + return out + def search( self, query: str, @@ -1698,26 +1953,80 @@ def _graph_stage(self, query: str, limit: int, layer: Optional[str] = None) -> L # Сортируем: определения выше использований (защита от перематчинга) refs_sorted = sorted(refs, key=lambda r: 0 if r.is_definition else 1) out: List[dict] = [] + project_path = getattr(self.indexer, "project_path", None) + from pathlib import Path as _Path + + from src.core.relpath import normalize_rel_path + for ref in refs_sorted[:limit]: - ref_layer = self._layer_from_path(ref.file_path) + ref_file = str(ref.file_path or "") + if ref_file: + try: + _p = _Path(ref_file) + if _p.is_absolute() and project_path: + ref_file = ( + _p.resolve() + .relative_to(_Path(project_path).resolve()) + .as_posix() + ) + else: + ref_file = normalize_rel_path(ref_file) + except Exception: + try: + ref_file = normalize_rel_path(ref_file) + except Exception: + ref_file = ref_file.replace("\\", "/") + ref_layer = self._layer_from_path(ref_file) if layer and ref_layer != layer: continue + + # Hydrate the graph pointer with the real chunk text when the + # SQLite store has it; otherwise keep the (short) pointer. + hydrated = None + if self._chunk_store is not None and ref_file: + try: + hydrated = self._chunk_store.get_symbol_chunk( + ref_file, ref.symbol + ) + if hydrated is None and "." in str(ref.symbol): + hydrated = self._chunk_store.get_symbol_chunk( + ref_file, str(ref.symbol).rsplit(".", 1)[-1] + ) + if hydrated is None: + hydrated = self._chunk_store.get_chunk_near_line( + ref_file, int(ref.line) + ) + except Exception: + hydrated = None + hmeta = (hydrated or {}).get("metadata") or {} + text = (hydrated or {}).get("text") or ( + f"{ref.kind} {ref.symbol}\n📍 {ref_file}:{ref.line}" + ) + meta = { + "file": ref_file, + "chunk_index": int( + hmeta.get("chunk_index", -(10_000_000 + ref.line)) + ), + "layer": ref_layer, + "symbol": ref.symbol, + "symbol_name": ref.symbol, + "line": ref.line, + "kind": ref.kind, + "graph_stage": True, + "is_symbol_ref": True, + "is_definition": ref.is_definition, + } + for k, v in hmeta.items(): + meta.setdefault(k, v) + meta.update({ + "graph_stage": True, + "is_symbol_ref": True, + "is_definition": ref.is_definition, + }) out.append({ - "text": f"{ref.kind} {ref.symbol}\n📍 {ref.file_path}:{ref.line}", - "metadata": { - "file": ref.file_path, - # Sentinel chunk_index: гарантированно уникален vs реальных - # чанков (положительные int) → не коллайдит в RRF-ключе. - "chunk_index": -(10_000_000 + ref.line), - "layer": ref_layer, - "symbol": ref.symbol, - "symbol_name": ref.symbol, - "line": ref.line, - "kind": ref.kind, - "graph_stage": True, - "is_symbol_ref": True, - "is_definition": ref.is_definition, - }, + "text": text, + "text_full": (hydrated or {}).get("text_full") or text, + "metadata": meta, "bm25_score": 0.0, "dense_score": 0.0, "fts5_score": 0.0, @@ -2045,7 +2354,9 @@ async def _apply_ondemand_reranker_async( t0 = time.perf_counter() try: top_n = max(1, min(self._ondemand_rerank_top_n, len(pool))) - _, usable, texts, skipped = split_embed_inputs(query, pool[:top_n]) + _, usable, texts, skipped = split_embed_inputs( + query, pool[:top_n], max_chars=self._ondemand_rerank_max_chars + ) if not usable: return pool embed_async = getattr(self.embedder, "embed_batch_async", None) diff --git a/src/core/search/fts5_mixin.py b/src/core/search/fts5_mixin.py index 3bc5a016..04383ca9 100644 --- a/src/core/search/fts5_mixin.py +++ b/src/core/search/fts5_mixin.py @@ -169,10 +169,15 @@ def remove_from_fts5(self, file_path: str) -> None: def _fts5_search(self, query: str, limit: int = 10) -> List[dict]: """FTS5 hybrid search — returns results with RRF scores. - This is the NEW tier that gets added to hybrid_search_async. - Results carry ``metadata.chunk_index`` (best-effort) so they merge - correctly via RRF with BM25/dense results keyed on file:chunk_index. + Phase 3: when the SQLite chunk store is active, query it directly + (persistent FTS5, no LanceDB table access). """ + if getattr(self, "_chunk_store", None) is not None: + try: + if self._chunk_store.count() > 0: + return self._chunk_store.search(query, limit) + except Exception as e: + logger.debug(f"SQLite FTS5 search error: {e}") self._build_fts5_index() if self._fts5 is None or self._fts5.is_empty: return [] diff --git a/src/core/search/graph_adapter.py b/src/core/search/graph_adapter.py index e1a17d33..4a0edbf0 100644 --- a/src/core/search/graph_adapter.py +++ b/src/core/search/graph_adapter.py @@ -983,6 +983,7 @@ def _should_skip_dir(self, dir_name: str) -> bool: "dist", "build", "target", ".tox", ".mypy_cache", ".ruff_cache", ".pytest_cache", "htmlcov", ".coverage", ".codebase_index", ".codebase_indices", ".codebase_models", + ".mscodebase", "experiments", ".zed", ".idea", ".vscode", "out", } return dir_name in skip_dirs diff --git a/src/core/search/ondemand_rerank.py b/src/core/search/ondemand_rerank.py index 3ddf2aa9..450efb54 100644 --- a/src/core/search/ondemand_rerank.py +++ b/src/core/search/ondemand_rerank.py @@ -52,8 +52,19 @@ def rerank_by_cosine( def split_embed_inputs( query: str, candidates: List[Dict[str, Any]], + max_chars: int = 1600, ) -> tuple[str, List[Dict[str, Any]], List[str], int]: - """(query, usable_cands, [query_text] + [doc_texts], skipped_no_text).""" + """(query, usable_cands, [query_text] + [doc_texts], skipped_no_text). + + E25 protocol trimmed candidate texts to ~400 tokens before embedding + (tokenize-trim). A char cap is a tokenizer-free approximation; it keeps + on-demand rerank latency bounded on long chunks. + """ usable = [c for c in candidates if (c.get("text_full") or c.get("text") or "")] - texts = [str(c.get("text_full") or c.get("text")) for c in usable] + texts = [] + for c in usable: + t = str(c.get("text_full") or c.get("text")) + if max_chars and len(t) > max_chars: + t = t[:max_chars] + texts.append(t) return query, usable, [query] + texts, len(candidates) - len(usable) diff --git a/src/core/search/query_plan.py b/src/core/search/query_plan.py new file mode 100644 index 00000000..afbe4c4a --- /dev/null +++ b/src/core/search/query_plan.py @@ -0,0 +1,157 @@ +"""Agent-facing query plan (E45). + +The agent supplies one primary query plus exact tokens/symbols; the server +executes one query and applies deterministic boosts. No multi-query flood. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Iterable, List + +__all__ = ["build_plan", "apply_plan_boosts", "normalize_list"] + +_RU_STOP = set( + """ + почему как что зачем насколько какой каким какая какие где когда это этот эта эти + и а но или же ли бы то все всё если чтобы может можно нужно надо делает делают + работает работают используется используются происходит происходят относится + является были был была есть не ни в на с со по для из к ко о об от до при + между через после перед под над без более менее самый самая самое + чем чего чему кем том того этому этих также только уже еще ещё + вот так там тут мы вы они он она оно + """.split() +) + +_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_\.:/-]*") +_SYMBOL_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{2,}$") + +_CONCEPTS = { + "auth": ["auth", "login", "session", "credential", "authorization", + "authenticate", "авторизация", "аутентификация", "логин", "сессия", + "токен", "доступ"], + "cache": ["cache", "caching", "кэш", "кэширование", "кэшировать"], + "database": ["database", "sqlite", "lancedb", "schema", "база", "данных"], + "config": ["config", "configuration", "settings", "конфиг", "настройка"], + "error": ["error", "exception", "failure", "ошибка", "исключение", "сбой"], + "rate_limit": ["rate limit", "rate_limiter", "throttle", "ограничитель", + "скорости", "лимит"], + "graph": ["graph", "node", "edge", "caller", "callee", "граф", "узел", + "ребро", "вызов"], + "search": ["search", "retrieval", "hybrid", "bm25", "fts5", "rerank", + "поиск", "поисковый", "реранкер"], + "index": ["index", "indexing", "reindex", "индекс", "индексация", + "переиндексация"], + "memory": ["memory", "recall", "prompt", "context", "память", "контекст"], + "concurrency": ["thread", "lock", "mutex", "async", "await", "race", + "поток", "блокировка", "мьютекс"], + "network": ["http", "request", "response", "server", "client", "port", + "запрос", "ответ", "сервер", "клиент", "порт"], + "file": ["file", "path", "module", "package", "файл", "путь", "модуль"], + "test": ["test", "pytest", "тест", "проверка"], + "process": ["process", "pid", "start", "stop", "процесс", "запуск"], + "security": ["security", "secret", "redact", "trust", "credential", + "безопасность", "секрет", "доверие"], +} + + +def normalize_list(value: Any) -> List[str]: + """Accept a list, comma/space-separated string, or None.""" + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return [str(x).strip() for x in value if str(x).strip()] + return [x.strip() for x in re.split(r"[,;\s]+", str(value)) if x.strip()] + + +def _is_symbol(tok: str) -> bool: + if not _SYMBOL_RE.match(tok): + return False + if any(ch.isupper() for ch in tok) or "_" in tok: + return True + return "." in tok or "/" in tok or "::" in tok + + +def build_plan(query: str) -> Dict[str, Any]: + """Deterministic plan from a natural-language question.""" + q = (query or "").strip() + if not q: + return { + "primary": "", + "must_include": [], + "symbols": [], + "concepts": [], + "intent": "auto", + "suggested_queries": [], + } + + tokens = re.findall(r"[\w\.:/-]+", q, flags=re.UNICODE) + primary_tokens = [] + for tok in tokens: + low = tok.lower().strip(".,!?;:()[]{}\"'«»") + if not low or low in _RU_STOP: + continue + primary_tokens.append(tok) + primary = " ".join(primary_tokens) or q + + must: List[str] = [] + for tok in _IDENT_RE.findall(q): + if len(tok) < 3: + continue + if any(ch.isupper() for ch in tok) or "_" in tok or "." in tok or "/" in tok: + if tok.lower() not in {m.lower() for m in must}: + must.append(tok) + symbols = [t for t in must if _is_symbol(t)] + + low = q.lower() + concepts = [ + c + for c, terms in _CONCEPTS.items() + if any(t.lower() in low for t in terms) + ] + + try: + from .scoring import auto_detect_intent + + intent = auto_detect_intent(q) + except Exception: # noqa: BLE001 - optional intent must not break planning + intent = "auto" + + return { + "primary": primary, + "must_include": must[:5], + "symbols": symbols[:5], + "concepts": concepts[:5], + "intent": intent, + "suggested_queries": [primary], + } + + +def apply_plan_boosts( + results: Iterable[Dict[str, Any]], + must_include: Any = None, + symbols: Any = None, + factor: float = 2.0, +) -> List[Dict[str, Any]]: + """Apply deterministic ×factor boosts for exact tokens/symbols.""" + must = [m.lower() for m in normalize_list(must_include)] + syms = {s.lower() for s in normalize_list(symbols)} + out = [] + for r in results: + meta = r.get("metadata") or {} + blob = " ".join( + [ + str(meta.get("symbol_name", "") or ""), + str(r.get("text_full") or r.get("text") or ""), + ] + ).lower() + score = float(r.get("final_score", 0.0) or 0.0) + if must and any(m in blob for m in must): + score *= factor + if syms and str(meta.get("symbol_name", "")).lower() in syms: + score *= factor + item = dict(r) + item["_plan_score"] = score + out.append(item) + out.sort(key=lambda r: r.get("_plan_score", 0.0), reverse=True) + return out diff --git a/src/core/search/scoring.py b/src/core/search/scoring.py index 5a3f007b..72052daa 100644 --- a/src/core/search/scoring.py +++ b/src/core/search/scoring.py @@ -183,6 +183,12 @@ def auto_detect_intent(query: str) -> str: r'\bguide\b', r'\btutorial\b', r'\bexample\b', r'\busage\b', r'\binstall\b', r'\bhow to\b', r'\bwhat is\b', r'\.md$', r'\.rst$', r'readme', r'changelog', r'license', + # E53: Russian doc-intent (E52: production docs hit@1 = 0). + r'докум', r'руководств', r'инструкц', r'описан', r'устроен', + r'как устро', r'установк', r'телеметр', r'границ', r'довер', + r'деград', r'adr', r'handoff', r'worktree', r'требован', + r'onnx', r'gguf', r'llama\.cpp', r'rrf', r'ранжирован', + r'active-узл', r'verify-on-read', r'zed', ] docs_score = sum(1 for p in docs_signals if re.search(p, q)) @@ -192,6 +198,10 @@ def auto_detect_intent(query: str) -> str: r'\bmodule\b', r'\bcomponent\b', r'\bdependency\b', r'\bcoupling\b', r'\bdiagram\b', r'\bflow\b', r'\bpattern\b', r'\bdesign\b', r'\bstructure\b', + # E53: Russian architecture/docs intent. + r'архитектур', r'схем', r'диаграм', r'компонент', + r'зависимост', r'структур', r'конвейер', r'pipeline', + r'поток', r'сло[йя]', r'поисков', ] arch_score = sum(1 for p in arch_signals if re.search(p, q)) diff --git a/src/core/search/synonyms_ru.py b/src/core/search/synonyms_ru.py new file mode 100644 index 00000000..0f3a2e9e --- /dev/null +++ b/src/core/search/synonyms_ru.py @@ -0,0 +1,212 @@ +"""Russian → English code-term synonyms for query expansion (E40). + +The existing `_QUERY_SYNONYMS` map is English-only; Russian queries get no +expansion. This dictionary covers common Russian code/engineering terms and +their inflected forms, so a Russian NL query can also match English identifiers. + +Curated, deterministic, no model. Keep entries code-specific; generic Russian +words are intentionally absent. +""" + +from __future__ import annotations + +__all__ = ["RU_EN", "RU_PHRASES", "expand_ru_variants"] + +# Single-word forms -> English code terms. +RU_EN = { + # cache + "кэш": ["cache"], "кэша": ["cache"], "кэшу": ["cache"], "кэшем": ["cache"], + "кэше": ["cache"], "кэши": ["cache"], "кэширование": ["cache", "caching"], + "кэширования": ["cache", "caching"], "кэшировать": ["cache"], + "кэшированный": ["cache"], "кэшированная": ["cache"], + # database / data + "база": ["database", "base"], "базы": ["database", "base"], + "базе": ["database"], "базу": ["database"], "базой": ["database"], + "данные": ["data"], "данных": ["data"], "данными": ["data"], + "данным": ["data"], "дата": ["date"], "даты": ["date"], + # error / exception + "ошибка": ["error"], "ошибки": ["error"], "ошибку": ["error"], + "ошибок": ["error"], "ошибками": ["error"], "ошибке": ["error"], + "исключение": ["exception"], "исключения": ["exception"], + "исключений": ["exception"], "сбой": ["failure", "error"], + "сбоя": ["failure", "error"], "сбои": ["failure", "error"], + # config / settings + "конфиг": ["config"], "конфига": ["config"], "конфигурация": ["configuration"], + "конфигурации": ["configuration"], "настройка": ["settings", "config"], + "настройки": ["settings", "config"], "настроек": ["settings"], + "настройке": ["settings"], "настройку": ["settings"], + # search / find + "поиск": ["search", "find"], "поиска": ["search"], "поиске": ["search"], + "искать": ["search", "find"], "найти": ["find"], "находит": ["find"], + "найден": ["find"], "поисковый": ["search"], "поисковая": ["search"], + # index + "индекс": ["index"], "индекса": ["index"], "индексы": ["index"], + "индексов": ["index"], "индексация": ["index", "indexing"], + "индексации": ["index", "indexing"], "индексировать": ["index"], + "переиндексация": ["reindex"], "переиндексации": ["reindex"], + # graph / nodes / edges + "граф": ["graph"], "графа": ["graph"], "графе": ["graph"], + "графы": ["graph"], "графов": ["graph"], "узел": ["node"], + "узла": ["node"], "узлы": ["node"], "узлов": ["node"], + "ребро": ["edge"], "ребра": ["edge"], "ребер": ["edge"], + "рёбер": ["edge"], "связь": ["relation", "edge"], "связи": ["relation"], + # vectors / embeddings + "вектор": ["vector"], "вектора": ["vector"], "векторы": ["vector"], + "векторов": ["vector"], "векторный": ["vector"], "векторная": ["vector"], + "эмбеддинг": ["embedding"], "эмбеддинги": ["embedding"], + "эмбеддингов": ["embedding"], "вложение": ["embedding"], + "вложения": ["embedding"], "вложений": ["embedding"], + "реранкер": ["reranker"], "реранкера": ["reranker"], "реранкинг": ["rerank"], + "переранжирование": ["rerank"], "переранжирования": ["rerank"], + # concurrency + "поток": ["thread"], "потока": ["thread"], "потоки": ["thread"], + "потоков": ["thread"], "очередь": ["queue"], "очереди": ["queue"], + "очередью": ["queue"], "блокировка": ["lock"], "блокировки": ["lock"], + "блокировку": ["lock"], "блокировок": ["lock"], "мьютекс": ["mutex"], + "мьютекса": ["mutex"], "мьютексы": ["mutex"], "асинхронный": ["async"], + "асинхронная": ["async"], "асинхронно": ["async"], "синхронный": ["sync"], + # process / server / client + "процесс": ["process"], "процесса": ["process"], "процессы": ["process"], + "процессов": ["process"], "сервер": ["server"], "сервера": ["server"], + "сервере": ["server"], "серверы": ["server"], "клиент": ["client"], + "клиента": ["client"], "клиенты": ["client"], "клиентов": ["client"], + "порт": ["port"], "порта": ["port"], "порту": ["port"], + "хост": ["host"], "хоста": ["host"], "адрес": ["address"], + "адреса": ["address"], "протокол": ["protocol"], "протокола": ["protocol"], + "соединение": ["connection"], "соединения": ["connection"], + "сеть": ["network"], "сети": ["network"], "сетевой": ["network"], + # files / paths / modules + "файл": ["file"], "файла": ["file"], "файлы": ["file"], "файлов": ["file"], + "путь": ["path"], "пути": ["path"], "путям": ["path"], "путей": ["path"], + "модуль": ["module"], "модуля": ["module"], "модули": ["module"], + "модулей": ["module"], "пакет": ["package"], "пакета": ["package"], + "класс": ["class"], "класса": ["class"], "классы": ["class"], + "классов": ["class"], "функция": ["function"], "функции": ["function"], + "функцию": ["function"], "функций": ["function"], "метод": ["method"], + "метода": ["method"], "методы": ["method"], "методов": ["method"], + "параметр": ["parameter"], "параметра": ["parameter"], + "параметры": ["parameter"], "параметров": ["parameter"], + "аргумент": ["argument"], "аргумента": ["argument"], + "аргументы": ["argument"], "возврат": ["return"], "возвращает": ["return"], + "вернуть": ["return"], "возвращаемое": ["return"], + # strings / numbers / collections + "строка": ["string", "line"], "строки": ["string", "line"], + "строку": ["string", "line"], "строк": ["string", "line"], + "число": ["number", "int"], "числа": ["number"], "чисел": ["number"], + "список": ["list"], "списка": ["list"], "списки": ["list"], + "словарь": ["dict", "dictionary"], "словаря": ["dict"], + "словари": ["dict"], "множество": ["set"], "множества": ["set"], + "ключ": ["key"], "ключа": ["key"], "ключи": ["key"], "ключей": ["key"], + "значение": ["value"], "значения": ["value"], "значений": ["value"], + "тип": ["type"], "типа": ["type"], "типы": ["type"], "типов": ["type"], + "объект": ["object"], "объекта": ["object"], "объекты": ["object"], + "интерфейс": ["interface"], "интерфейса": ["interface"], + # actions + "удаление": ["delete", "remove"], "удалить": ["delete", "remove"], + "удаляет": ["delete", "remove"], "создание": ["create"], + "создать": ["create"], "создает": ["create"], "обновление": ["update"], + "обновить": ["update"], "обновляет": ["update"], "чтение": ["read"], + "читать": ["read"], "читает": ["read"], "запись": ["write"], + "записать": ["write"], "записывает": ["write"], "загрузка": ["load"], + "загрузить": ["load"], "сохранение": ["save"], "сохранить": ["save"], + "запуск": ["start", "run"], "запустить": ["start", "run"], + "остановка": ["stop"], "остановить": ["stop"], "ожидание": ["wait"], + "ждать": ["wait"], "повтор": ["retry"], "повтора": ["retry"], + "повторный": ["retry"], "повторно": ["retry"], "проверка": ["check", "validate"], + "проверки": ["check"], "проверить": ["check", "validate"], + "валидация": ["validation"], "валидации": ["validation"], + "парсер": ["parser", "parse"], "парсера": ["parser"], + "парсинг": ["parse"], "парсить": ["parse"], "токен": ["token"], + "токена": ["token"], "токены": ["token"], "токенизация": ["tokenize"], + "реализация": ["implementation", "implement"], "реализации": ["implementation"], + "реализовать": ["implement"], "вызов": ["call"], "вызова": ["call"], + "вызовы": ["call"], "вызывать": ["call"], "вызывается": ["caller"], + "ссылка": ["reference", "link"], "ссылки": ["reference"], + "зависимость": ["dependency"], "зависимости": ["dependency"], + "импорт": ["import"], "импорта": ["import"], "импортировать": ["import"], + "экспорт": ["export"], "экспорта": ["export"], + # memory / model / metrics + "память": ["memory"], "памяти": ["memory"], "модель": ["model"], + "модели": ["model"], "моделей": ["model"], "размер": ["size"], + "размера": ["size"], "размеры": ["size"], "длина": ["length"], + "длины": ["length"], "скорость": ["speed"], "скорости": ["speed"], + "время": ["time"], "времени": ["time"], "версия": ["version"], + "версии": ["version"], "ветка": ["branch"], "ветки": ["branch"], + "коммит": ["commit"], "коммита": ["commit"], "репозиторий": ["repository"], + "репозитория": ["repository"], "проект": ["project"], "проекта": ["project"], + "система": ["system"], "системы": ["system"], "компонент": ["component"], + "компонента": ["component"], "слой": ["layer"], "слоя": ["layer"], + "слои": ["layer"], "архитектура": ["architecture"], + "архитектуры": ["architecture"], + # misc + "логи": ["log", "logs"], "лог": ["log"], "логов": ["log"], + "тест": ["test"], "теста": ["test"], "тесты": ["test"], + "тестов": ["test"], "тестирование": ["test", "testing"], + "диск": ["disk"], "диска": ["disk"], "таймаут": ["timeout"], + "таймаута": ["timeout"], "таймауты": ["timeout"], + "конкурентность": ["concurrency"], "конкурентный": ["concurrent"], + "приоритет": ["priority"], "буфер": ["buffer"], "буфера": ["buffer"], + "семафор": ["semaphore"], "событие": ["event"], "события": ["event"], + "обработчик": ["handler"], "обработчика": ["handler"], + "плагин": ["plugin"], "плагина": ["plugin"], "расширение": ["extension"], + "расширения": ["extension"], "хук": ["hook"], "хука": ["hook"], +} + +# Multi-word phrases -> single English term. +RU_PHRASES = { + "база данных": "database", + "базы данных": "database", + "базе данных": "database", + "обработка ошибок": "error handling", + "обработки ошибок": "error handling", + "поиск по коду": "code search", + "поиска по коду": "code search", + "векторный поиск": "vector search", + "векторного поиска": "vector search", + "полнотекстовый поиск": "full text search", + "полнотекстового поиска": "full text search", + "граф вызовов": "call graph", + "графа вызовов": "call graph", + "утечка памяти": "memory leak", + "утечки памяти": "memory leak", + "состояние гонки": "race condition", + "состояния гонки": "race condition", + "блокировка потока": "thread lock", + "блокировки потока": "thread lock", +} + + +def expand_ru_variants( + query: str, + variants: list[str], + max_expansions: int = 4, +) -> list[str]: + """Append variants with Russian terms replaced by English code terms.""" + if not query.strip(): + return variants + + q_low = query.lower() + # Phrase-level replacements first. + for ru, en in RU_PHRASES.items(): + if ru in q_low: + variant = query.replace(ru, en) + if variant not in variants: + variants.append(variant) + if len(variants) >= max_expansions: + return variants + + words = query.split() + for i, raw in enumerate(words): + key = raw.strip(".,!?;:()[]{}\"'«»").lower() + syns = RU_EN.get(key) + if not syns: + continue + for syn in syns: + new_words = list(words) + new_words[i] = syn + variant = " ".join(new_words) + if variant not in variants: + variants.append(variant) + if len(variants) >= max_expansions: + return variants + return variants diff --git a/src/core/system_artifacts.py b/src/core/system_artifacts.py index a2d105b3..735bfea2 100644 --- a/src/core/system_artifacts.py +++ b/src/core/system_artifacts.py @@ -130,9 +130,10 @@ # experiments/**/results|work (ctx-дампы по 329 чанков, judged_raw.json — # 249). Душит лексику (кейс P2) и раздувает холодный FTS-билд (2.47s). # Git-трекинг НЕ трогаем (§17: frozen/results обязаны жить в репо) — -# исключаем только из ИНДЕКСА. Исходники экспериментов (*.py) индексируются. +# исключаем только из ИНДЕКСА. E52: experiments/** целиком (включая frozen), +# потому что frozen-панели утекают в BM25; E41 подтвердил отсутствие потери +# качества и ускорение поиска. _EXPERIMENT_ROOT = "experiments" -_EXPERIMENT_OUTPUT_DIRS = frozenset({"results", "work"}) # ─── Public API ───────────────────────────────────────────── @@ -234,18 +235,15 @@ def is_feedback_risk(cls, path: Path) -> bool: @classmethod def is_experiment_output(cls, path: Path) -> bool: - """Проверяет, является ли файл выводом эксперимента. + """Проверяет, является ли файл артефактом experiments/. - experiments/**/results/** и experiments/**/work/** — сырьё прогонов - (ctx-дампы, judged_raw.json, work-файлы). В индекс не берём; - frozen/-входы и *.py-исходники — берём. + E52: experiments/** целиком исключаем из индекса. Раньше пропускались + только results/work, а frozen-панели индексировались и утекали в BM25 + (E30 panel: 19/48 запросов находили сам файл панели). E41 показал, что + удаление experiments/** не ухудшает hit@1 и ускоряет поиск (81→43 ms). + Git-трекинг не трогаем (§17): frozen/results остаются в репозитории. """ - parts = [p.lower() for p in Path(path).parts] - try: - i = parts.index(_EXPERIMENT_ROOT) - except ValueError: - return False - return any(p in _EXPERIMENT_OUTPUT_DIRS for p in parts[i + 1 :]) + return _EXPERIMENT_ROOT in {p.lower() for p in Path(path).parts} # ─── Layer 5: Measurement Hygiene ─────────────────────── diff --git a/src/mcp/server_tools.py b/src/mcp/server_tools.py index 8304e368..f7ae9550 100644 --- a/src/mcp/server_tools.py +++ b/src/mcp/server_tools.py @@ -90,6 +90,7 @@ def register_all_tools(mcp, services): from src.mcp.tools.search_tools import ( GetSymbolInfoTool, ImpactAnalysisTool, + PlanSearchTool, SearchCodeTool, ) @@ -99,6 +100,7 @@ def register_all_tools(mcp, services): SetProjectTool, # Search (3) SearchCodeTool, + PlanSearchTool, GetSymbolInfoTool, ImpactAnalysisTool, # LSP (6) — точный AST через basedpyright (вариант C+D+E, 2026-08-06/07) @@ -174,6 +176,7 @@ def register_all_tools(mcp, services): # (см. server_tools.py:140); без этого класс не существует и allowlist пуст. # ML-native (не заменяются E2B) "search_code", + "plan_search", "get_symbol_info", "impact_analysis", "lsp_find_references", diff --git a/src/mcp/tools/search_tools.py b/src/mcp/tools/search_tools.py index 6661ff94..0916f877 100644 --- a/src/mcp/tools/search_tools.py +++ b/src/mcp/tools/search_tools.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import json import logging import time from datetime import datetime @@ -17,6 +18,7 @@ from src.config.settings import get_config from src.core.di_container import ServiceCollection from src.core.error_handler import error_boundary +from src.core.search.query_plan import apply_plan_boosts, build_plan from src.core.utils.grep_fallback import grep_fallback as _grep_fallback from src.mcp.tools.base import MCPTool from src.utils.i18n import _ @@ -207,6 +209,10 @@ async def execute( intent_hint: str = "auto", explain: bool = False, project_root: str = "", + primary_query: str = "", + must_include: Optional[list[str]] = None, + symbols: Optional[list[str]] = None, + intent: str = "", kwargs: Optional[Dict[str, Any]] = None, ) -> str: from src.core.error_handler import record_tool_result @@ -243,7 +249,65 @@ async def execute( raw = None # для телеметрии # === Диспетчеризация по режиму === - if mode in ("fast", "quality", "smart"): + # E57: cheap automatic docs routing (BM25 margin; no LLM/router). + # Explicit intent="code" still wins; empty intent is auto. + if not intent: + _auto_searcher = self.resolve_searcher(explicit_project_root=_pr or None) + if _auto_searcher.prefers_docs(query): + intent = "docs" + + if intent == "docs": + # E56: explicit docs intent -> dedicated docs-only FTS channel. + # No LLM/router; agent knows it wants documentation. + _searcher = self.resolve_searcher(explicit_project_root=_pr or None) + _t0 = time.perf_counter() + _docs = _searcher.search_docs(query, limit=effective_limit) + raw = { + "results": _docs, + "mode": "quality", + "timing_ms": {"total_ms": (time.perf_counter() - _t0) * 1000}, + "cache_hit": False, + "model_info": "docs_fts", + "rerank_timing": {}, + "query": query, + } + results_count = len( + [r for r in _docs if SearchCodeTool._is_real_result(r)] + ) + result_str = stale_banner + self._format_results( + raw, "quality", project_header=project_header + ) + elif primary_query or must_include or symbols: + # E45: agent-supplied plan — one primary query + exact boosts. + _searcher = self.resolve_searcher(explicit_project_root=_pr or None) + _q = (primary_query or "").strip() or query + _t0 = time.perf_counter() + _pool = await _searcher.hybrid_search_async( + _q, + limit=max(30, effective_limit * 4), + expand=False, + layer=filter_layer, + intent_hint=intent or intent_hint, + ) + _boosted = apply_plan_boosts( + _pool, must_include, symbols + )[:effective_limit] + raw = { + "results": _boosted, + "mode": "quality", + "timing_ms": {"total_ms": (time.perf_counter() - _t0) * 1000}, + "cache_hit": False, + "model_info": "plan", + "rerank_timing": {}, + "query": _q, + } + results_count = len( + [r for r in _boosted if SearchCodeTool._is_real_result(r)] + ) + result_str = stale_banner + self._format_results( + raw, "quality", project_header=project_header + ) + elif mode in ("fast", "quality", "smart"): # INC-6D31: sync search_with_mode — в отдельный поток (asyncio.to_thread). # Внутри (hybrid_search) в этом потоке НЕТ running loop → берётся ветка # asyncio.run(hybrid_search_async) напрямую — тот путь, который @@ -782,8 +846,26 @@ def _safe_count(v): } +class PlanSearchTool(MCPTool): + """plan_search — deterministic query plan for an agent. + + Returns primary query, exact must_include tokens, symbols, concepts and + intent. The agent can pass the plan fields to search_code. + """ + + def __init__(self, services: ServiceCollection): + super().__init__(services, tool_name="plan_search") + + async def execute(self, query: str, project_root: str = "") -> str: + if not query or not query.strip(): + return _("❌ Query is empty") + plan = build_plan(query) + return json.dumps(plan, ensure_ascii=False, indent=2) + + __all__ = [ "SearchCodeTool", + "PlanSearchTool", "GetSymbolInfoTool", "ImpactAnalysisTool", "_is_complex_query", diff --git a/src/providers/reranker/llama_install.py b/src/providers/reranker/llama_install.py index fe6228cb..41f10f35 100644 --- a/src/providers/reranker/llama_install.py +++ b/src/providers/reranker/llama_install.py @@ -25,7 +25,7 @@ logger = logging.getLogger("mscodebase_server.llama_install") # ─── Конфигурация ────────────────────────────────────────────── -LLAMA_VERSION = "b9940" # ⚠️ перед бампом проверь https://github.com/ggml-org/llama.cpp/security (GHSA-advisories) +LLAMA_VERSION = "b11476" # first release supporting gemma-embedding2 arch (PR #30054) LLAMA_BASE_URL = ( f"https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_VERSION}" ) @@ -256,6 +256,14 @@ def _is_windows_insider() -> bool: "ctx_recommended": 512, "pooling": "mean", }, + "embeddinggemma-2": { + "repo": "unsloth/embeddinggemma-2-GGUF", + "file": "embeddinggemma-2-Q8_0.gguf", + "size_mb": 296, + "dim": 768, + "ctx_recommended": 8192, + "pooling": "mean", + }, "bge-m3": { "repo": "lm-kit/bge-m3-gguf", "file": "bge-m3-Q4_K_M.gguf", @@ -274,7 +282,7 @@ def _is_windows_insider() -> bool: # Модель по умолчанию (можно переопределить через EMEDDING_MODEL) # qwen3-embedding — лучшее качество (722 MB RAM, 2.4 чанка/с) # bge-m3 — в 5x быстрее индексация (692 MB RAM, 12 чанков/с) -DEFAULT_EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "multilingual-e5-small") +DEFAULT_EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "embeddinggemma-2") DEFAULT_RERANKER_MODEL = "bge-reranker-v2-m3" # На Insider (build >= 26000): MSVC сборка не может загрузить api-ms-win-crt-* DLL diff --git a/tests/test_chunk_store_delete_files.py b/tests/test_chunk_store_delete_files.py new file mode 100644 index 00000000..4d54418c --- /dev/null +++ b/tests/test_chunk_store_delete_files.py @@ -0,0 +1,40 @@ +from src.core.indexing.chunk_store import SQLiteChunkStore + + +def _rec(file_path, idx, text): + return { + "chunk_id": f"{file_path}:{idx}", + "file_path": file_path, + "chunk_index": idx, + "text": text, + "text_full": text, + "symbol_name": "", + "symbol_kind": "", + "docstring": "", + "layer": "", + "start_line": 1, + "end_line": 1, + "indexed_at": "", + "chunk_hash": "h", + "file_hash": "h", + "file_mtime_ns": 0, + "file_size": 1, + } + + +def test_delete_files_prunes_and_keeps_fts_in_sync(tmp_path): + db = tmp_path / "chunks.db" + store = SQLiteChunkStore(db) + store.upsert_records([ + _rec("src/a.py", 0, "alpha"), + _rec("experiments/x/frozen/q.md", 0, "beta"), + _rec("docs/a.md", 0, "gamma"), + ]) + assert store.count() == 3 + + store.delete_files(["experiments/x/frozen/q.md"]) + assert store.count() == 2 + assert store.search("beta", limit=10) == [] + + store.delete_files(["src/a.py", "docs/a.md"]) + assert store.count() == 0 diff --git a/tests/test_code_tokenizer.py b/tests/test_code_tokenizer.py new file mode 100644 index 00000000..60d2bce6 --- /dev/null +++ b/tests/test_code_tokenizer.py @@ -0,0 +1,22 @@ +from src.core.search.code_tokenizer import split_identifier, tokenize_code + + +def test_split_identifier_cases(): + assert split_identifier("parseJsonConfig") == ["parse", "json", "config"] + assert split_identifier("my_func_name") == ["my", "func", "name"] + assert split_identifier("HTML5Parser") == ["html", "5", "parser"] + assert split_identifier("MAX_RETRY_2") == ["max", "retry", "2"] + assert split_identifier("parser") == ["parser"] + + +def test_tokenize_code_keeps_raw_and_subwords(): + toks = tokenize_code("authenticateUser", stopwords=False) + assert toks[0] == "authenticateuser" + assert "authenticate" in toks and "user" in toks + + +def test_tokenize_code_stopwords(): + toks = tokenize_code("the function parseJsonConfig", stopwords=True) + assert "the" not in toks + assert "function" not in toks + assert "parse" in toks and "json" in toks and "config" in toks diff --git a/tests/test_experiment_output_guard.py b/tests/test_experiment_output_guard.py index 69a21346..6438b39a 100644 --- a/tests/test_experiment_output_guard.py +++ b/tests/test_experiment_output_guard.py @@ -1,7 +1,9 @@ -"""Guard: выводы экспериментов не индексируются (experiments/**/results|work). +"""Guard: experiments/** не индексируется (E52). -Замер 2026-09-28: 3127/15426 чанков (20.3%) — мусор (ctx-дампы, judged_raw). -Git-трекинг не трогаем (§17) — только индекс. +Раньше исключались только results/work; frozen-панели индексировались и +утекали в BM25 (E51: 19/48 запросов находили файл панели). E41: удаление +experiments/** не ухудшает hit@1 и ускоряет поиск. Git-трекинг не трогаем +(§17) — только индекс. """ from __future__ import annotations @@ -23,11 +25,11 @@ def test_results_and_work_excluded(): ) -def test_sources_and_frozen_kept(): - assert not SystemArtifacts.is_experiment_output( +def test_all_experiments_excluded(): + assert SystemArtifacts.is_experiment_output( Path("experiments/4A_unit_of_return/run_experiment.py") ) - assert not SystemArtifacts.is_experiment_output( + assert SystemArtifacts.is_experiment_output( Path("experiments/4A_unit_of_return/frozen/f5/queries.jsonl") ) assert not SystemArtifacts.is_experiment_output(Path("src/core/search/engine.py")) @@ -43,4 +45,4 @@ def test_fileguard_skips_experiment_output(tmp_path): src = tmp_path / "experiments" / "x" / "run.py" src.write_text("x = 1\n", encoding="utf-8") - assert guard.is_safe_to_index(src) is True + assert guard.is_safe_to_index(src) is False diff --git a/tests/test_phase3_sqlite_store.py b/tests/test_phase3_sqlite_store.py new file mode 100644 index 00000000..9322f62b --- /dev/null +++ b/tests/test_phase3_sqlite_store.py @@ -0,0 +1,118 @@ +"""Phase 3: SQLite/FTS5 chunk store + LanceDB-free search path.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from src.core.indexing.chunk_store import SQLiteChunkStore, get_chunks_db_path +from src.core.search.engine import Searcher + + +def _records(): + return [ + { + "chunk_id": "src/a.py:0", + "file_path": "src/a.py", + "chunk_index": 0, + "text": "def remote_embedder(): return provider", + "text_full": "def remote_embedder(): return provider", + "symbol_name": "remote_embedder", + "symbol_kind": "function", + "layer": "providers", + "start_line": 10, + "end_line": 12, + }, + { + "chunk_id": "src/b.py:0", + "file_path": "src/b.py", + "chunk_index": 0, + "text": "class DatabaseLock: pid lock", + "text_full": "class DatabaseLock: pid lock", + "symbol_name": "DatabaseLock", + "symbol_kind": "class", + "layer": "core", + "start_line": 1, + "end_line": 5, + }, + ] + + +def test_store_upsert_and_search(tmp_path: Path): + db = tmp_path / "chunks.db" + store = SQLiteChunkStore(db) + try: + assert store.upsert_records(_records()) == 2 + assert store.count() == 2 + assert store.files_count() == 2 + hits = store.search("remote embedder provider", limit=5) + assert hits + assert hits[0]["metadata"]["file"] == "src/a.py" + assert store.file_hash("src/a.py") is None + finally: + store.close() + + +def test_store_replaces_file_chunks(tmp_path: Path): + db = tmp_path / "chunks.db" + store = SQLiteChunkStore(db) + try: + store.upsert_records(_records()) + store.upsert_records( + [ + { + "chunk_id": "src/a.py:0", + "file_path": "src/a.py", + "chunk_index": 0, + "text": "def changed(): pass", + "text_full": "def changed(): pass", + } + ] + ) + assert store.count() == 2 + hits = store.search("changed", limit=5) + assert hits and hits[0]["metadata"]["file"] == "src/a.py" + assert not store.search("remote_embedder", limit=5) + finally: + store.close() + + +class _RaisingTable: + """Any LanceDB table access in the search path must fail loudly.""" + + def __getattr__(self, name): + raise AssertionError(f"LanceDB table touched: {name}") + + +class _FakeIndexer: + db_manager = None + _symbol_index = None + table = _RaisingTable() + + def __init__(self, project_path: Path): + self.project_path = project_path + + +class _FakeEmbedder: + def embed_batch(self, texts, is_query=False): + return [[1.0] * 384 for _ in texts] + + async def embed_batch_async(self, texts, is_query=False): + return self.embed_batch(texts, is_query=False) + + +def test_search_uses_sqlite_and_never_touches_lancedb(tmp_path: Path): + store_path = get_chunks_db_path(tmp_path) + store = SQLiteChunkStore(store_path) + store.upsert_records(_records()) + store.close() + + searcher = Searcher(_FakeIndexer(tmp_path), _FakeEmbedder()) + try: + results = asyncio.run( + searcher.hybrid_search_async("remote embedder provider", limit=5) + ) + assert results + assert results[0]["metadata"]["file"] == "src/a.py" + finally: + asyncio.run(searcher.close()) diff --git a/tests/test_query_plan.py b/tests/test_query_plan.py new file mode 100644 index 00000000..8728b390 --- /dev/null +++ b/tests/test_query_plan.py @@ -0,0 +1,43 @@ +from src.core.search.query_plan import apply_plan_boosts, build_plan, normalize_list + + +def test_build_plan_identifiers(): + plan = build_plan( + "Почему в ограничителе скорости используется threading.Lock, а не asyncio.Lock?" + ) + assert "threading.Lock" in plan["must_include"] + assert "asyncio.Lock" in plan["must_include"] + assert plan["primary"] + assert "скорости" in plan["primary"].lower() + + +def test_build_plan_symbols(): + plan = build_plan("Fix RemoteEmbedder cache path") + assert "RemoteEmbedder" in plan["must_include"] + assert "RemoteEmbedder" in plan["symbols"] + assert "cache" in plan["concepts"] + + +def test_normalize_list(): + assert normalize_list("a, b; c") == ["a", "b", "c"] + assert normalize_list(["a", " b "]) == ["a", "b"] + assert normalize_list(None) == [] + + +def test_apply_plan_boosts(): + results = [ + {"metadata": {"symbol_name": "foo", "file": "a.py"}, "text": "x", "final_score": 1.0}, + {"metadata": {"symbol_name": "RemoteEmbedder", "file": "b.py"}, "text": "remote", "final_score": 1.0}, + ] + out = apply_plan_boosts(results, must_include=["RemoteEmbedder"], symbols=[]) + assert out[0]["metadata"]["symbol_name"] == "RemoteEmbedder" + assert out[0]["_plan_score"] == 2.0 + +def test_russian_docs_intent(): + from src.core.search.scoring import auto_detect_intent + + assert auto_detect_intent("Как устроена архитектура поискового конвейера?") == "docs" + assert auto_detect_intent("Что описано в README про установку?") == "docs" + assert auto_detect_intent("Где проходит граница доверия и что означает trust boundary?") == "docs" + assert auto_detect_intent("Почему в ACTIVE-узлы памяти не проверяются verify-on-read?") == "docs" + assert auto_detect_intent("Почему парсер .gitignore использует формат gitignore?") != "docs" diff --git a/tests/test_search_docs.py b/tests/test_search_docs.py new file mode 100644 index 00000000..ffc34fd2 --- /dev/null +++ b/tests/test_search_docs.py @@ -0,0 +1,64 @@ +from src.core.search.engine import Searcher + + +class _FakeStore: + def search(self, query, limit=20): + return [ + {"metadata": {"file": "src/a.py"}, "text": "code", "final_score": 3.0}, + {"metadata": {"file": "docs/a.md"}, "text": "doc a", "final_score": 2.0}, + {"metadata": {"file": "docs/a.md"}, "text": "doc a dup", "final_score": 1.9}, + {"metadata": {"file": "docs/b.md"}, "text": "doc b", "final_score": 1.0}, + ] + + +def test_search_docs_filters_and_dedupes(): + searcher = Searcher.__new__(Searcher) + searcher._chunk_store = _FakeStore() + out = searcher.search_docs("q", limit=2) + assert [r["metadata"]["file"] for r in out] == ["docs/a.md", "docs/b.md"] + assert all(r["source"] == "docs_fts" for r in out) + + +def test_search_docs_without_store(): + searcher = Searcher.__new__(Searcher) + searcher._chunk_store = None + assert searcher.search_docs("q") == [] + + +class _LangStore: + def search(self, query, limit=20): + return [ + {"metadata": {"file": "docs/en/X.md"}, "text": "x en", "final_score": 4.0}, + {"metadata": {"file": "docs/ru/X.md"}, "text": "x ru", "final_score": 3.9}, + {"metadata": {"file": "docs/en/Y.md"}, "text": "y en", "final_score": 3.0}, + {"metadata": {"file": "docs/ru/Y.md"}, "text": "y ru", "final_score": 2.9}, + ] + + +def test_search_docs_collapses_language_variants(): + searcher = Searcher.__new__(Searcher) + searcher._chunk_store = _LangStore() + out = searcher.search_docs("русский запрос", limit=5) + assert [r["metadata"]["file"] for r in out] == ["docs/ru/X.md", "docs/ru/Y.md"] + + +class _ScoreStore: + def __init__(self, docs, code): + self._rows = [ + {"metadata": {"file": "docs/a.md"}, "final_score": docs}, + {"metadata": {"file": "src/a.py"}, "final_score": code}, + ] + + def search(self, query, limit=20): + return self._rows + + +def test_prefers_docs_margin(): + searcher = Searcher.__new__(Searcher) + searcher._docs_margin = 4.0 + searcher._chunk_store = _ScoreStore(10.0, 5.0) + assert searcher.prefers_docs("q") is True + searcher._chunk_store = _ScoreStore(5.0, 10.0) + assert searcher.prefers_docs("q") is False + searcher._chunk_store = _ScoreStore(8.0, 4.5) + assert searcher.prefers_docs("q") is False # margin 3.5 < 4.0 From cae02aed6d294fcd34e73a8c6e10248e5947d99e Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 15:10:48 +0300 Subject: [PATCH 02/12] docs: rewrite README and architecture/search guides for Phase 3 Align EN/RU/ZH main pages with the text-first runtime, measured quality and its limits. Rewrite ARCHITECTURE and SEARCH_PIPELINE for the default SQLite/FTS5 path and mark the historical deep-dive explicitly. Correct tool counts, Python requirement and the docs routing contract; remove unverified 95% and stale LanceDB-first claims. --- AGENT_DIARY.md | 304 ++++++++++++ EXPERIMENTS_LOG.md | 548 +++++++++++++++++++++ README.md | 789 +++++++++++------------------- docs/en/ARCHITECTURE.md | 911 ++++++++++------------------------- docs/en/ARCHITECTURE_DEEP.md | 9 +- docs/en/CONTRIBUTING.md | 2 +- docs/en/INSTALL.md | 6 +- docs/en/SEARCH_PIPELINE.md | 482 ++++++++---------- docs/ru/ARCHITECTURE.md | 894 +++++++++------------------------- docs/ru/ARCHITECTURE_DEEP.md | 8 +- docs/ru/CONTRIBUTING.md | 2 +- docs/ru/INSTALL.md | 6 +- docs/ru/README.md | 727 +++++++++++----------------- docs/ru/SEARCH_PIPELINE.md | 483 ++++++++----------- docs/zh/ARCHITECTURE.md | 877 ++++++++------------------------- docs/zh/ARCHITECTURE_DEEP.md | 8 +- docs/zh/CONTRIBUTING.md | 2 +- docs/zh/INSTALL.md | 6 +- docs/zh/README.md | 676 ++++++++++---------------- docs/zh/SEARCH_PIPELINE.md | 470 ++++++++---------- 20 files changed, 2992 insertions(+), 4218 deletions(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 927e74ce..21086e6d 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1,4 +1,308 @@ + + + + + + + + + + + +## [2026-10-25] DOCS — синхронизация RU/ZH главных страниц и статус-баннеры + +- `docs/ru/README.md` и `docs/zh/README.md` переписаны по новому English README: + Phase 3 text-first, измеренное качество с методикой, ограничения, bug-report + протокол, актуальные команды и карта документации. +- В `ARCHITECTURE`, `ARCHITECTURE_DEEP`, `SEARCH_PIPELINE` (EN/RU/ZH) добавлены + статус-баннеры: часть текста — исторический LanceDB/vector design; канон — + README. В `ARCHITECTURE` обновлены счётчики инструментов и тестов. +- `CONTRIBUTING` EN/RU/ZH: Python 3.10+ → 3.14+. +- Проверка локальных ссылок RU/ZH: 0 missing. + +## [2026-10-25] README — полная переписка главной страницы + +- Старый README описывал LanceDB/vector/reranker как основной путь и содержал + взаимоисключающие числа (65/49 tools, 2131/2142/853 tests). Переписан под + фактическую Phase 3: SQLite/FTS5 + PropertyGraph, optional providers. +- Добавлены: «What works today», измеренное качество с методикой и границами, + известные ограничения, bug-report протокол, актуальные команды install/reindex/ + rebuild_graph, группы инструментов (64 registered / 49 default-visible). +- Числа: quality 0.688/0.750; code 0.825/0.900; docs exact 0/0.125; same-basename + 0.750; auto docs route 0.792/0.875 (translation-normalized). 0.95 не заявлено. +- RU/ZH зеркала помечены как могущие отставать; канон — English README. + +## [2026-10-25] E57 — auto docs routing by BM25 margin (no LLM) + +- Keyword router: base h1 0.708, 4 code false positives. +- **BM25 margin `docs_max - code_max > 4.0`:** same-basename **0.688 → 0.792**, + docs 5/8, **0 code false positives**, code h1 0.825 unchanged; exact 0.708. +- Held-out odd/even: t=3 odd best → even 0.875; t=4 full 0.792. +- **Реализовано:** `MSCODEBASE_DOCS_MARGIN` (4.0), `Searcher.prefers_docs()`, + auto-ветка в `SearchCodeTool` при пустом `intent`; tests 27 pass. +- Без LLM: один доп. FTS-запрос (~100 ms), затем docs или code. + +## [2026-10-25] E55/E56 — chain modes + docs: gold-language, не ranking + +- **E55:** hybrid/quality/deep = 0.688/0.771 (p50 45–48 ms); fast = 0.542/0.729 + (p50 19 ms); nodemote/intent/ru/p3 — **без эффекта**. +- **E56:** exact-path docs_only 0.021/0.062; **translation-normalized docs_only + 0.750**, oracle **0.812/0.917**; p50 131 ms. BM25 отдаёт `docs/ru/X.md` при + gold `docs/en/X.md` — это **gold canonicalization**, не провал retrieval. +- **Дешёвый путь:** explicit intent + docs-only FTS, без LLM-router; + `demote/intent/ru/p3` на панели не двигают hit@1. +- **Реализовано:** `Searcher.search_docs()` + `search_code(intent="docs")`; + docs: exact 1/8, same-basename **6/8**; tests `test_search_docs.py`. +- **Добавлено:** language-dedupe в `search_docs` (ru/en/zh → один документ, + предпочтение языка запроса) + `delete_files()`/prune в reindex; tests 12/7 pass. +- Production: graph 12,753 nodes, index 7,314 chunks (0 experiments), tests 32 pass. + +## [2026-10-25] AUDIT — цепочка: graph=0 исправлен, leakage убран, docs=0 + +- **P0 graph:** production graph был 0 nodes; `index_project` блокируется + `DENSE_OFF`. Добавлен `scripts/rebuild_graph.py`; восстановлено + **12,753 nodes / 45,762 edges**, 0 experiment nodes. +- **P0 leakage:** `experiments/frozen` индексировался → 19/48 BM25 находили + панель. `SystemArtifacts` теперь исключает весь `experiments/**`; тест + обновлён. Production index: **0 experiments, 7,314 chunks** (FTS synced). +- **E53/E54:** lexical intent + explicit intent не поднимают docs hit@1 (0); + только +1 docs hit@5. Нужен отдельный docs-only FTS (E48: 0.75/1.00), без LLM. +- Baseline clean: **0.688/0.750**; code 0.825/0.900; docs 0/0. Tests 32 pass. + +## [2026-10-25] E52 — clean-index audit: baseline исправлен, docs = 0 + +- Clean DB: 7,258 chunks (удалено 9,089 experiments); leakage 0/48. +- Contaminated production 0.688/0.771; **clean production 0.688/0.750** (p50 72ms). +- Clean BM25 0.542/0.771; code 0.625/0.875; docs 0.125/0.25. +- **Production subsets: code 0.825/0.900, docs 0.000/0.000.** +- **Вывод:** hit@1 не завышен; hit@5 0.771 → 0.750 (supersede). Docs — главный + провал. Oracle router (code 0.825 + docs 0.75) = **0.8125 hit@1 / ~0.9167 hit@5**. + 95% всё ещё требует усиления code-канала/LLM-агента. + +## [2026-10-25] E51/E51b — agentic loop + панель проиндексирована (leakage) + +- **E51:** single_top1 = `experiments/4A.../frozen/f5/queries.md` для большинства + запросов — frozen-файл панели в индексе и содержит те же probes. Цифры невалидны. +- **E51b (фильтр experiments/):** single 0.562/0.771; agent 0.438/0.792; + p50 140 ms; calls 2.0; tokens 1048. Code single 0.650/0.875, agent 0.525/0.875. +- **Вывод:** детерминированный expansion+graph ухудшает hit@1; для 95% нужен + LLM-планировщик, а не эвристика. **P0:** исключить frozen-панели из индекса/ + retrieval и перепроверить все BM25-числа (E49/E50/E51). + +## [2026-10-25] E49/E50 — резерв docs и score-router: бюджет нулевой суммы + +- **E49:** reserve15 0.250/0.500/0.583 (хуже base 0.271 на 1k); reserve25 + 0.208/0.458/0.604; oracle 0.396/0.604/0.729; p50 245 ms. +- **E50:** route@0.8–2.0 = 0.104/0.125/0.167; docs-запросы 8/8 при любом + пороге (BM25-score docs всегда выше); code route 0; oracle 0.375/0.583/0.729. +- **Урок:** docs и code конкурируют за один token-budget; лексический score — + не сигнал маршрутизации. Нужен semantic classifier/agentic loop, статическая + map не даёт 95% (потолок ~37–40% на 1k). + +## [2026-10-25] E48 — two-channel map: docs BM25 сильный, routing ломает всё + +- Code PageRank + docs BM25, один PageRank/запрос; p50 323 ms. +- **base** 0.271/0.458/0.562; **route** 0.292/0.458/0.562; **oracle** + 0.396/0.625/0.729; best real interleave 0.208/0.438/0.625. +- Docs-gold (8): **bm25_docs 0.75/1.00/1.00**, route 0.125 (keyword-роутер + поймал 2/8). Code-gold: base 0.325/0.550/0.675. +- **Потолок двух каналов на 1k — 39.6%**, 2k 62.5%, 4k 72.9%; 95% недостижимо. +- **Урок:** docs всегда через BM25 (без classifier), top-docs под token-reserve; + code-канал 1k улучшать BM25+PageRank hybrid. H1–H5,H7,H8 FAIL; H6 PASS. + +## [2026-10-25] E47 — docs в PageRank разбавляют code-граф (FAIL) + +- Docs 348 + code 759; docs→code по упоминаниям; BM25 seed; RU→EN. +- **base** (code-only): 0.271/0.458/0.562; **docs**: 0.042/0.188/0.458; + **docs_bm25**: 0.042/0.229/0.479; p50 1,113 ms. +- Code-only 1k: 32.5% → 5%; docs-only 1k: 0 → 37.5% (BM25). +- **Урок:** docs нельзя в тот же PageRank — два канала (code PageRank + docs BM25), + слияние по бюджету; один PageRank на запрос. H1–H7 FAIL (H7 ожидаемо). + +## [2026-10-25] E46/E46b — Aider-style PageRank repo map: работает, но далёк от 95% + +- **Граф:** complete PropertyGraph 9,315 nodes / 47,286 edges / 759 code files (docs нет). +- **E46:** personalized PageRank 1k/2k/4k = 0.271/0.417/0.521 (code-only 0.325/0.500/0.625; + docs 0/0/0). Degree/global — 0–0.17. p50 207ms. +- **E46b (+RU→EN):** 0.271/0.458/0.562 (code-only 0.325/0.550/0.675); p50 204ms. +- **Вывод:** PageRank реально бьёт degree/global, но 95% на 1k недостижимо; docs-gold + не попадают в граф. Нужны docs-узлы, лучшая персонализация и/или BM25 top-files до PageRank. + Артефакты: `experiments/e46_pagerank_map/`, `e46b_pagerank_ru/`. + +## [2026-10-25] E45 — structured agent plan: +1 h1 (0.688 → 0.708), p50 72 ms + +- **План:** primary keyword query + must_include + symbols; один запрос, детерминированные ×2 бусты, + без multi-query RRF. 48 запросов E30, frozen plans sha `093003f4…`. +- **Результат:** baseline 0.688/0.771/79ms; plan_primary 0.708/0.750/69ms; + **plan_must 0.708/0.771/71ms**; plan_full 0.708/0.771/72ms. +- **Вывод:** один primary query + must_include — реальный маленький выигрыш (+1 h1, h5 сохранён, + быстрее). Exact symbols ничего не дают (пусто/шум). Агентский план стоит внедрить как API/UX, + но 95% он не закрывает. Артефакты: `experiments/e45_agent_plan/`. + +## [2026-10-25] E44 — agent-style rewrites: keywords +1 h1; identifier/fusion хуже + +- **Плечи:** baseline / keywords / identifiers / agent_all на 48 запросах E30; frozen rewrites + sha `9f87fbb2…`. +- **Результат:** baseline 0.688/0.771/84ms; **keywords 0.708/0.771/78ms**; + identifiers 0.583/0.750; agent_all 0.688/0.771/147ms. +- **Вывод:** одна чистая keyword-формулировка лучше флуда вариантов; identifier-extraction шумит, + fusion всех вариантов разбавляет RRF. Агентский план должен выбирать **один primary query**, + а не несколько. Артефакты: `experiments/e44_agent_rewrite/`. + +## [2026-10-25] E41–E43 chain audit — experiments pollution, docs intent, concept tags + +- **Диагностика 48 запросов:** BM25 top5 28/48, FTS5 34/48, RRF 35/48; 13/48 gold вне top5. + Из них 8 — docs gold (demote/bucket), остальные — recall (gold нет в top50). +- **E41:** исключение `experiments/` из store — качество 0.688/0.771 → 0.688/0.750, p50 81→43 ms. + Query-файлы уже демотируются; выигрыш только в скорости/шуме. +- **E42:** intent-aware demote — 0.688/0.771 без изменений; `auto_detect_intent` не распознаёт + docs-запросы (code-сигналы перевешивают), docs_h1=0/8. +- **E43:** concept-tags ×0.9 — 0.688/0.750 (не входит в top5); ×1.1 — **0.188/0.250** + (concept-кандидаты вытеснили всё). Наивный keyword-count concept boost не работает; + нужен точный symbol-level слой аннотаций. +- **Вывод:** цепочка не сломана в одном месте; это ограничения docs-intent, broad concepts, + recall misses и строгий docs gold. Детерминированный pipeline остаётся 0.688/0.771. + Артефакты: `experiments/e41_no_experiments/`, `e42_intent_demote/`, `e43_concepts/`. + +## [2026-10-25] E40 — RU→EN словарь синонимов: ноль (0.688/0.771 без изменений) + +- **Плечи:** baseline / ru_synonyms на 48 запросах E30; словарь `synonyms_ru.py` + (~150 форм + фразы), варианты до 4, RRF; corrected index, demote ON, P3 off. +- **Результат:** baseline h1 0.688/h5 0.771/p50 159ms; ru_synonyms **те же** 0.688/0.771/p50 176ms. + H6 PASS; H1/H2/H3/H4/H5 FAIL. +- **Вывод:** добавленные варианты дают те же top-5; RRF схлопывает дубликаты. Словарь + оставлен как утилита (флаг off), не рычаг. 95% на этой панели недостижимо. + Артефакты: `experiments/e40_ru_synonyms/`. + +## [2026-10-25] E39 pilot — Qwen3-Reranker-0.6B: 29–55 s, качество хуже; rejected + +- **Пилот:** 16 запросов E30 (каждый 3-й), corrected index; Qwen3-Reranker-0.6B-seq-cls ONNX + (1.19 GB fp16, CPU), model-card prompt. +- **Результат:** demote h1 0.625/h5 0.688/p50 116ms; Qwen3 top10 h1 **0.500**/h5 0.562/p50 **28.9s**; + top20 0.438/0.562/p50 **54.8s**. H1–H6 FAIL. +- **Вывод:** Qwen3 не usable на CPU; качество ниже deterministic. Все reranker'ы (MiniLM, BGE-base, + BGE-M3, Qwen3) проиграли. Reranking — не рычаг; deterministic 0.688/0.771/84ms — потолок на панели. + Артефакты: `experiments/e39_qwen3_rerank/`. + +## [2026-10-25] E38 — BGE-reranker-base: снова хуже deterministic (0.688 → 0.625) + +- **Плечи:** demote / bge_base_top10 / top20; ONNX int8 279 MB, CPU. +- **Результат:** demote h1 0.688/h5 0.771/p50 84ms; BGE-base top10 h1 **0.625**/h5 0.708/p50 3139ms; + top20 0.583/0.688/p50 6028ms. H7 PASS (лучше MiniLM 0.417); H1/H2/H3/H4/H5/H6 FAIL. +- **Вывод:** generic cross-encoder снова out-of-domain для кода; deterministic pipeline сильнее. + Reranking не рычаг. Остаётся только Qwen3-Reranker-0.6B (1.2 GB ONNX) как пилот. + Артефакты: `experiments/e38_code_aware_rerank/`. + +## [2026-10-25] E37 — chunking 32/64/128 строк: качество не меняется; P3 off + +- **Панель:** 48 запросов E30, corrected index 16,347 чанков; E33 demote ON, P3 off. +- **Результат:** current/split32/split64/split128 — все h1 0.688 / h5 0.771 / MRR 0.713. + Нарезка не узкое место: чанки уже ≤32 строк (13 992/16 347), top-5 — file-level ranking. +- **P3:** после фикса читает `get_file_head`, но даёт 0 качества и +96 ms без reranker → + `MSCODEBASE_P3_ANCHOR=false` по умолчанию. Полный пайплайн: 0.688/0.771, p50 **87 ms** + без P3. +- **Вывод:** детерминированные tokenizer/chunking/graph/PRF исчерпаны; дальше — code-aware + reranker или query expansion/semantic anchors. Артефакты: `experiments/e37_chunking/`. + +## [2026-10-25] E36-audit — symbol_name был пуст в text-only индексе; graph/P3 баги; corrected baseline + +- **Root cause:** `write_text_records` брал `symbol_name` из `chunk_metadatas`, а парсер его не клал → + **0 / 16 158** чанков с symbol_name. FTS5/BM25 field boosts, exact-name, graph hydration были мертвы; + E32–E35 измеряли повреждённый индекс. +- **Fix:** маппинг AST-символов на line-range в `IndexPipeline.process_file`; reindex → + **4 772 / 16 347** чанков с symbol_name. Graph-stage: hydration реального чанка, relative paths, + fallback `get_chunk_near_line`. P3: был мёртв (`_bm25_df=None`), теперь читает + `SQLiteChunkStore.get_file_head`. +- **Corrected E36 full:** h1 **0.688**, h5 **0.771**, p50 183 ms; `no_p3` p50 87 ms + (P3 +96 ms без качества); `no_bucket` h1 0.562; `no_o1` 0.667; `no_demote` 0.625; + `minimal` 0.542/0.750. +- **Вывод:** главные детерминированные рычаги — bucket weights, demote non-code, O1; P3 полезен + только с reranker. Symbol_name fix поднял h5. E32/E33/E35 требуют пере-прогона на исправленном индексе. + Артефакты: `experiments/e36_ablation/`. + +## [2026-10-24] E35 — MS MARCO MiniLM rerank: out-of-domain, качество 0.688 → 0.417 + +- **Плечи:** demote / minilm_top10 / minilm_top20; ONNX int8 23 MB, CPU. +- **Результат:** demote h1 0.688/h5 0.729/p50 85ms; minilm_top10 h1 **0.417**/h5 0.646/p50 723ms; + top20 h1 0.417/h5 0.542/p50 1405ms. H4 PASS; **H1/H2/H3/H5/H6/H7 FAIL**. +- **Вывод:** web-passage cross-encoder не подходит для кода; нужен code-aware reranker + (Qwen3-Reranker-0.6B) или recall-работа. Лучший результат — E33 demote. + Артефакты: `experiments/e35_small_rerank/`. + +## [2026-10-24] E34b — сильные graph/PRF boosts: снова ноль + +- **Плечи:** demote / graph_x15 / prf_x12 / both_x15. Все h1 0.688/h5 0.729/p50 93–94ms. +- **Вывод:** усиление boost не помогает; добавленные кандидаты не gold/дубликаты. + Детерминированное расширение пула исчерпано. Артефакты: `experiments/e34b_boost/`. + +## [2026-10-24] E34 — graph expansion + PRF: нулевой результат, top-5 не изменился + +- **Плечи:** demote / +PRF / +graph / +graph+PRF; 48 запросов E30, без моделей. +- **Результат:** все четыре h1 0.688 / h5 0.729 / MRR 0.705 / p50 108–119ms — идентично. + H5 PASS; **H1/H2/H3/H4/H6/H7 FAIL**. +- **Вывод:** PRF/graph-кандидаты (score 0.8×/0.6× top-1) не входят в top-5; расширение пула + само по себе не двигает выдачу. Нужен ranker или сильный deterministic boost. E33 demote — + единственный детерминированный выигрыш. Дальше — маленький cross-encoder (E35). + Артефакты: `experiments/e34_graph_prf/`. + +## [2026-10-24] E33 — детерминированный demote non-code: +3 hit@1 (0.625 → 0.688) + +- **Панель:** 48 запросов E30; baseline / fts_weights / +demote / +prior+exact. +- **Результат:** baseline h1 0.625/h5 0.708/p50 155ms; **demote h1 0.688**/h5 0.729/p50 **97ms**; + fts_weights нейтральны; prior+exact хуже (0.667). H2/H5 PASS; H1/H3/H4/H6 FAIL. +- **Вывод:** demote `tests/docs/experiments/fixtures ×0.3` — реальный дешёвый выигрыш. + Добавить в прод под флагом; caller prior не включать. 95% не достигнуто; дальше — + graph expansion + маленький cross-encoder. Артефакты: `experiments/e33_deterministic/`. + +## [2026-10-24] E32 — code-aware BM25: h1 не вырос, h5 +1; узкое место — recall/ranking + +- **Плечи:** baseline / code_b75 / code_b45 / code_b45_stop; 48 запросов E30, без эмбеддингов/реранкера. +- **Результат:** baseline h1 0.625/h5 0.729; code_b45_stop h1 0.604/**h5 0.750**; code_b75 h1 0.562. + H2/H4 PASS; **H1/H3/H5 FAIL**. +- **Вывод:** identifier split + field boosts не дают h1; stopwords+b45 дают +1 h5 при том же p50 (90 ms). + Токенизация не узкое место — нужны graph expansion, query expansion/PRF, маленький cross-encoder, + semantic anchors. `MSCODEBASE_CODE_BM25` оставить false. Артефакты: `experiments/e32_code_bm25/`. + +## [2026-10-24] E31 — BGE-M3 rerank: лучший h1 0.646, но 6.6–19.5 s; 95% не достигнуто + +- **Панель:** 48 запросов E30 (sha `d4934cb7…`); no_rerank vs BGE top10/top30/top50. + `top50` не завершён — harness timeout 30 мин. +- **Результат:** no_rerank h1 0.625/h5 0.729/p50 107ms; BGE top10 h1 **0.646**/h5 0.771/p50 **6577ms**; + BGE top30 h1 0.562/h5 **0.812**/p50 **19521ms**. H2/H5/H6 PASS; H1/H3/H7 FAIL; H4 ⏳. +- **Вывод:** BGE top10 — лучший h1, но 60× медленнее; top30 даёт h5, но теряет h1 и 19.5 s. + 95% (ни h1, ни h5) не достигнуто. BGE-M3 Q4 слишком тяжёл для CPU; следующий шаг — маленький + ONNX cross-encoder + code-aware BM25/recall. Артефакты: `experiments/e31_bge_rerank/`. + +## [2026-10-24] E30 — on-demand rerank проиграл fused retrieval на 48 реальных запросах + +- **Панель:** 48 запросов (33 E28 + 15 свежих docstring), заморожена `d4934cb7…`; Q8/Q4/e5, + MRL 256, top10/top20, cap800, clean no-rerank control. +- **Результат:** `no_rerank` h1 **0.625**, p50 **109 ms**; Q8 768 h1 0.604 (4.3–5.1 s); + Q4 768 h1 0.562 (1.9–3.9 s); e5 h1 0.354 (0.9–1.1 s). H2/H5 PASS, H4/H6/H8 FAIL как ожидалось, + **H1/H3/H7 FAIL** (H7: no-rerank на 1 запрос лучше Q8). +- **Вывод:** on-demand rerank не даёт качества на этой панели и стоит ~40× latency; + держать флаг выключенным до панели >100 запросов. E25 не отменён (другой пул), но его вывод + нельзя переносить на прод-панель. Артефакты: `experiments/e30_prod_rerank/`. + +## [2026-10-24] Phase 3 — vector-free search: SQLite/FTS5 chunk store + on-demand rerank (implemented) + +- **Сделано:** `src/core/indexing/chunk_store.py` — SQLite/FTS5 store текста/метаданных без векторов; миграция + **13 645 чанков / 1 072 файла** из LanceDB в `/.mscodebase/chunks.db`; candidate retrieval → SQLite + FTS5 (+ BM25 из SQLite), dense-тир выключен; on-demand rerank top-N живыми эмбеддингами; флаги + `SQLITE_CHUNKS`/`DENSE_OFF`/`ONDEMAND_RERANK`/`AUGMENT_CHUNKS` включены по умолчанию; full vector reindex + заблокирован в `index_project`; инкрементальный путь (`skip_vectors`) пишет только SQLite. +- **Проверено:** 96 search/index тестов + 3 новых Phase 3 теста зелёные; negative control `RaisingTable` + (любое обращение к LanceDB падает) → поиск проходит; SQLite retrieval ~25 ms; on-demand e5 + (llama.cpp e5-small, 11 текстов/400 ток) ~0.55 s embed, end-to-end p50 ~0.85 s; hit1 3–4/33 на + E28-frozen (ниже EXP-28 dense_off 12/33 — e5 on-demand rerank слабее BGE; зафиксировано честно). +- **Открыто:** latency target <0.6 s end-to-end не достигнут (tokenizer-trim + e5); quality ниже BGE-пути; + варианты: fast rerank-модель (MiniLM ~0.49 s) или BGE для quality-режима. +- **Guard:** `tests/test_phase3_sqlite_store.py` (upsert/replace, searcher не трогает LanceDB). +- **Файлы:** `src/core/indexing/chunk_store.py` (new), `src/core/indexing/{db_writer,indexer,index_pipeline,chunk_augment}.py`, + `src/core/search/{engine,bm25,fts5_mixin,ondemand_rerank}.py`, `src/config/settings.py`, + `scripts/migrate_chunks_to_sqlite.py`, `.env.example`, `tests/test_phase3_sqlite_store.py`. + ## [2026-10-10] PR-конвейер: три инцидента, где «код зелёный» ≠ «PR в порядке» - **#73 стал CONFLICTING после мержа #72 в main.** Merge разрешился сам в дневнике (union), diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index 1c87fb97..718320be 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1,4 +1,547 @@ # EXPERIMENTS_LOG.md — Audit Verification (2026-07-22) +## [2026-10-25] — E57: automatic docs routing without LLM (BM25 margin) + +**Keyword router:** exact 0.625/0.708, same-basename 0.708/0.812; 4 code FP. +**BM25 margin router** (`docs_max - code_max > 4.0`): +``` +t=2 base_h1=0.729 docs=5/8 code_fp=4 +t=3 base_h1=0.771 docs=5/8 code_fp=2 +t=4 base_h1=0.792 docs=5/8 code_fp=0 <-- implement +t=5 base_h1=0.792 docs=5/8 code_fp=0 +``` +Exact overall at t=4 = 0.708; same-basename **0.688 → 0.792**, code hit@1 +unchanged 0.825, 0 code false positives. Held-out odd/even: best odd t=3 → +even 0.875; t=4 full 0.792. + +**Implemented:** `MSCODEBASE_DOCS_MARGIN` (default 4.0), +`Searcher.prefers_docs()`, auto branch in `SearchCodeTool` when `intent` empty. +Tests: `tests/test_search_docs.py` (4). No LLM router. + + +## [2026-10-25] — E55/E56: chain modes + docs channel; gold-language bottleneck + +**E55 (real chain, clean index + graph 12,753 nodes):** +``` +hybrid h1=0.688 h5=0.771 code 0.825/0.900 docs 0.000/0.125 p50=45ms +fast h1=0.542 h5=0.729 code 0.625/0.850 docs 0.125/0.125 p50=19ms +quality h1=0.688 h5=0.771 code 0.825/0.900 docs 0.000/0.125 p50=48ms +deep h1=0.688 h5=0.771 +nodemote/intent/ru/p3 — все 0.688/0.771, без эффекта. +``` +**E56 (docs-only FTS):** exact-path docs_only 0.021/0.062 (docs subset 0.125/0.375); +**translation-normalized (same basename) docs_only 0.750**, oracle **0.812/0.917**; +p50 131ms. + +**Вывод:** docs-канал не слабый — BM25 возвращает `docs/ru/X.md`, а gold панели +указывает `docs/en/X.md`; **gold canonicalization** (языковые варианты) — +бутылочное горлышко, не ранжирование. Дешёвый путь: explicit intent + +docs-only FTS (без LLM-router) → **0.812/0.917**. Параметры `demote/intent/ru/p3` +на этой панели не двигают hit@1. + + +## [2026-10-25] — E53/E54: cheap intent + explicit intent — docs всё ещё 0 hit@1 + +**E53 (lexical intent-aware, clean index):** +``` +baseline h1=0.688 h5=0.750 code 0.825/0.900 docs 0.000/0.000 p50=88ms +intent h1=0.688 h5=0.771 code 0.825/0.900 docs 0.000/0.125 p50=86ms +``` +False-positive docs intent на code-gold: **4** (E28-29, E28-32, D01, D12). + +**E54 (explicit intent по gold):** baseline 0.688/0.750; explicit 0.688/0.771; +code 0.825/0.900; docs 0.000/0.125. Даже идеальный intent не двигает docs hit@1. +**Вывод:** docs отсутствуют в candidate pool; нужен отдельный docs-only FTS +канал (E48: docs-only BM25 = 0.75/1.00), без LLM-router. + +## [2026-10-25] — AUDIT: цепочка и модули (graph=0, leakage, docs=0) + +- **P0 graph:** production `graph.db` был 57 KB / **0 nodes**; `index_project` + блокируется `DENSE_OFF` → граф не строится. Восстановлен graph-only rebuild: + **12,753 nodes / 45,762 edges**, 0 experiment nodes. +- **P0 leakage:** `experiments/frozen` индексировался → 19/48 BM25 запросов + находили саму панель. Исправлено: `SystemArtifacts` исключает `experiments/**`; + тест обновлён. Production index очищен: **0 experiments, 7,314 chunks**. +- **E53/E54:** lexical intent даёт только +1 docs hit@5; docs остаются 0 hit@1. +- Baseline clean: **0.688 hit@1 / 0.750 hit@5**; code 0.825/0.900, docs 0/0. +- Tests: 32 passed (`query_plan`, experiment guard, searcher, fts5, graph). + + +## [2026-10-25] — E52: clean-index baseline audit (leakage quantified) + +**Clean index:** удалено 9,089 из 16,347 chunks (`experiments/**`, `.mscodebase/**`), +осталось 7,258. Leakage-файл в clean top-100: **0/48** (в contaminated — 19/48). + +``` +contaminated BM25 hit@1=0.458 hit@5=0.750 p50=185ms +contaminated production hit@1=0.688 hit@5=0.771 p50=144ms +clean BM25 hit@1=0.542 hit@5=0.771 p50=140ms +clean production hit@1=0.688 hit@5=0.750 p50=72ms +``` +Subsets clean: BM25 code 0.625/0.875, docs 0.125/0.250; +production **code 0.825/0.900, docs 0.000/0.000**. + +**Вердикты:** H1 FAIL (0.542 < 0.55); H2–H9 PASS; H10 FAIL (docs 0). +**Вывод:** production hit@1 **не завышен** (0.688 = 0.688); hit@5 был слегка +завышен (0.771 → **0.750**); BM25 был повреждён leakage (0.458 → 0.542). +Production силён на code (0.825/0.900), но **ноль на docs**. Oracle с идеальным +router: **0.8125 hit@1 / ~0.9167 hit@5**; 95% всё ещё нет. + + +## [2026-10-25] — E51/E51b: agentic loop + **panel leakage found** + +**E51 (contaminated):** `single_top1` для большинства запросов = +`experiments/4A_unit_of_return/frozen/f5/queries.md` — frozen-файл панели +проиндексирован и содержит те же probes. BM25 возвращает саму панель. +Цифры E51 непригодны для качества. + +**E51b (фильтр `experiments/**`):** +``` +single hit@1=0.562 hit@5=0.771 +agent hit@1=0.438 hit@5=0.792 +p50=140ms calls=2.0 tokens=1048 +``` +Code (40): single 0.650/0.875; agent 0.525/0.875. +Docs (8): single 0.125/0.250; agent 0.000/0.375. + +**Вердикты:** H1/H2/H3/H5/H8/H9/H10 FAIL; H4/H6/H7/H11 PASS. +**Вывод:** детерминированный expansion+graph **ухудшает hit@1** (0.438 vs 0.562), +hit@5 +2.1 п.п. Главный блокер — **index hygiene**: frozen-панели нельзя +индексировать/искать. Настоящий agentic loop требует LLM-планировщика, +а не фиксированной эвристики. + + +## [2026-10-25] — E49/E50: hybrid + docs reserve / score routing — zero-sum + +**E49 (резерв docs):** base 0.271/0.458/0.562; reserve15 0.250/0.500/0.583; +reserve25 0.208/0.458/0.604; oracle 0.396/0.604/0.729; p50 245 ms. +Code: reserve15 0.250/0.525/0.600. Docs: reserve15 0.250/0.375/0.500; +reserve25 0.375/0.500/0.750. **Вывод:** бюджет нулевой суммы; резерв 15% +ухудшает 1k (0.250 < 0.271); H1–H7,H9 FAIL. + +**E50 (score router):** base 0.271; docs 0.104/0.125/0.167; oracle +0.375/0.583/0.729; все route@0.8–2.0 = 0.104/0.125/0.167; p50 460 ms. +Docs-запросы route: **8/8 при любом пороге** — BM25-score docs всегда выше. +Code route 0; docs route 0.625/0.750/1.000. **Вывод:** лексический score — +не сигнал маршрутизации; нужен semantic classifier/agentic loop. +Артефакты: `experiments/e49_hybrid_reserve/`, `e50_score_route/`. + + +## [2026-10-25] — E48: two-channel repo map (code PageRank + docs BM25) + +**Вопрос:** сработает ли двухканальная схема после провала E47 (docs dilution). + +**Вход:** 759 code / 348 docs; один PageRank + один BM25 на запрос; слияние +route/interleave/split; oracle — верхняя граница с идеальной маршрутизацией. +**Сырой вывод:** +``` +base 1k=0.271 2k=0.458 4k=0.562 +bm25_docs 1k=0.125 2k=0.167 4k=0.167 +route 1k=0.292 2k=0.458 4k=0.562 +interleave 1k=0.208 2k=0.438 4k=0.625 +split75 1k=0.208 2k=0.438 4k=0.604 +split50 1k=0.146 2k=0.396 4k=0.625 +oracle 1k=0.396 2k=0.625 4k=0.729 +p50=323ms +``` +Docs-gold (8): **bm25_docs 0.75/1.00/1.00**; route 0.125; interleave 0.375/0.375/0.75; +oracle 0.75/1.00/1.00. Code-gold (40): base 0.325/0.550/0.675; oracle same. + +**Вердикты:** H1–H5, H7, H8 FAIL; H6 PASS (323 ms). +**Вывод:** docs BM25 — сильный отдельный канал (75/100/100), но keyword-routing +ловит лишь 2 из 8 docs-запросов; oracle-потолок 39.6% на 1k (чуть ниже 40%), +62.5% на 2k, 72.9% на 4k. **95% недостижимо** с этими двумя каналами. +**Урок:** не классифицировать docs по словам — всегда запрашивать docs BM25 и +добавлять top-docs под небольшой token-reserve; code-канал на 1k улучшать +BM25-top-files + PageRank hybrid. + + +## [2026-10-25] — E47: PageRank + docs nodes + BM25 seed — docs dilution + +**Вопрос:** поднимут ли docs-узлы и BM25-seed покрытие repo map. + +**Вход:** 759 code + 348 docs = 1,107 files; docs→code по упоминаниям символов; +RU→EN; BM25 top-20 seed. **Сырой вывод:** +``` +bm25 1k=0.062 2k=0.062 4k=0.062 +base 1k=0.271 2k=0.458 4k=0.562 +docs 1k=0.042 2k=0.188 4k=0.458 +docs_bm25 1k=0.042 2k=0.229 4k=0.479 +p50=1113ms +``` +Code-only: base 0.325/0.550/0.675; docs 0.050/0.225/0.475. +Docs-only: base 0/0/0; docs 0/0/0.375; docs_bm25 0/0.375/0.500; bm25 0.375/0.375/0.375. + +**Вердикты:** H1–H6 FAIL; H7 FAIL (как ожидалось: BM25 overall < PageRank). +**Вывод:** наивные docs-узлы **разбавляют** code-граф (code 1k 32.5%→5%); +BM25-seed не сливает каналы; docs выигрывает только через отдельный BM25 +(37.5% на 1k). p50 1.1 c — три PageRank на запрос слишком медленно. +**Урок:** docs не в тот же PageRank; два канала (code PageRank + docs BM25) и +слияние по бюджету/типу запроса, один PageRank на запрос. + + +## [2026-10-25] — E46/E46b: Aider-style personalized PageRank repo map + +**Вопрос:** даёт ли file-level graph + personalized PageRank (Aider-style) +высокое покрытие gold-файлов в token-budgeted repo map. + +**Вход:** E30 48 запросов; complete PropertyGraph 9,315 nodes / 47,286 edges / +759 code files (docs в графе нет); бюджеты 1k/2k/4k; без эмбеддингов/LLM. + +**Сырой вывод (E46):** +``` +degree 1k=0.000 2k=0.021 4k=0.146 +global 1k=0.021 2k=0.042 4k=0.104 +personalized 1k=0.271 2k=0.417 4k=0.521 +code-only 1k=0.325 2k=0.500 4k=0.625 +docs 1k=0.000 2k=0.000 4k=0.000 +``` +E46b (+RU→EN personalization): personalized 0.271/0.458/0.562; code-only +0.325/0.550/0.675; docs 0.000. p50 ~205ms. + +**Вердикты:** H2 PASS (personalized ≫ global), H5 PASS, H6 PASS (≫ degree). +**H1/H3/H4 FAIL** (1k 32.5% code, 2k 50%, 4k 62.5% — ниже 60/80/95). +E46b H1 FAIL (1k не вырос), H2/H3/H4 FAIL; RU→EN даёт +5 п.п. на 2k/4k. + +**Вывод:** PageRank — реальный context-selection механизм, но не замена поиску и +не 95% на 1k. Для высокого покрытия нужны docs-узлы в графе, лучшая +персонализация (RU→EN+точные символы) и/или BM25 top-files до PageRank. +Артефакты: `experiments/e46_pagerank_map/`, `e46b_pagerank_ru/`. + + +## [2026-10-25] — E45: structured agent plan — +1 h1 (0.688 → 0.708), p50 72 ms + +**Вопрос:** даёт ли структурированный план (primary + must_include + symbols) +выигрыш над текущим query expansion, без флуда вариантов. + +**Вход:** E30 48 запросов; frozen plans sha `093003f4…`; corrected index, demote ON, +P3 off, без реранкера/графа. + +**Сырой вывод:** +``` +baseline h1=0.688 h5=0.771 mrr=0.713 p50=79ms +plan_primary h1=0.708 h5=0.750 mrr=0.718 p50=69ms +plan_must h1=0.708 h5=0.771 mrr=0.722 p50=71ms +plan_symbols h1=0.708 h5=0.750 mrr=0.718 p50=72ms +plan_full h1=0.708 h5=0.771 mrr=0.722 p50=72ms +``` + +**Вердикты:** H1 PASS (+1 h1), H5 PASS (p50 <150ms). H2/H3/H4/H6 FAIL +(must/symbols не дают +1 h1; full 0.708 <0.75; 95% нет). + +**Вывод:** один primary query + must_include даёт +1 h1 и сохраняет h5, быстрее baseline. +Агентский план — реальльный UX/латентный выигрыш, но не закрывает 95%. +Артефакты: `experiments/e45_agent_plan/`. + + +## [2026-10-25] — E44: agent-style rewrites — keywords +1 h1; identifier/fusion хуже + +**Вопрос:** если агент даст несколько формулировок запроса, улучшит ли это fused retrieval. + +**Вход:** E30 48 запросов; frozen rewrites sha `9f87fbb2…` (original, keywords, +identifiers, RU→EN, concepts); corrected index, demote ON, P3 off, без реранкера. + +**Сырой вывод:** +``` +baseline h1=0.688 h5=0.771 mrr=0.713 p50=84ms +keywords h1=0.708 h5=0.771 mrr=0.723 p50=78ms +identifiers h1=0.583 h5=0.750 mrr=0.652 p50=69ms +agent_all h1=0.688 h5=0.771 mrr=0.713 p50=147ms +``` + +**Вердикт:** keywords-only — маленький выигрыш (+1 h1, +0.010 MRR, быстрее). +Identifier-only хуже (шумная экстракция); фьюжн всех вариантов нейтрален и медленнее. +Идея агентского планирования верна, но нужен **один primary query**, а не флуд вариантов. +Артефакты: `experiments/e44_agent_rewrite/`. + + +## [2026-10-25] — E41–E43: chain audit — experiments pollution, docs intent, concept tags + +**Диагностика цепочки (48 запросов):** +``` +BM25 top1/top5/top10/top50 = 18/28/33/38 +FTS5 top1/top5/top10/top50 = 22/34/36/41 +RRF top1/top5/top10/top50 = 25/35/37/40 +gold not in RRF top5 = 13/48; из них 8 — docs gold +``` +Причина misses: часть — recall (gold нет в top50), часть — docs gold вытеснен +demote/bucket weights; `auto_detect_intent` классифицирует docs-запросы как code. + +**E41 (исключить experiments/):** baseline 0.688/0.771; no_experiments 0.688/0.750 +(p50 81→43 ms). Качество не выросло; query-файлы уже демотируются, но latency/шум меньше. + +**E42 (intent-aware demote):** baseline и intent-aware оба 0.688/0.771; docs_h1=0/8. +`auto_detect_intent` не распознал docs-запросы (code-сигналы перевесили). + +**E43 (concept tags):** baseline 0.688/0.750; concept ×0.9 — 0.688/0.750 (не входит в top5); +concept ×1.1 — **0.188/0.250** (concept-кандидаты вытеснили всё). Наивный concept boost +не работает: теги слишком широкие, нужна точность/ручные аннотации. + +**Вывод:** цепочка не «сломана» в одном месте; это набор ограничений: +docs-intent misclassification, broad concept tags, recall misses, strict docs gold. +Детерминированный pipeline остаётся 0.688/0.771. Для concept-фичи нужен отдельный +точный слой аннотаций (symbol-level), а не keyword-count boost. + + +## [2026-10-25] — E40: RU→EN словарь синонимов — ноль (0.688/0.771 без изменений) + +**Вопрос:** улучшает ли curated RU→EN словарь кодовых терминов (+фразы) recall +на 48 реальных запросах (в основном русский NL). + +**Вход:** E30-панель; `synonyms_ru.py` (RU_EN + RU_PHRASES), варианты до 4, RRF; +corrected index, demote ON, P3 off, без реранкера. + +**Сырой вывод:** +``` +baseline h1=0.688 h5=0.771 mrr=0.713 p50=159ms +ru_synonyms h1=0.688 h5=0.771 mrr=0.713 p50=176ms +``` + +**Вердикты:** H6 PASS (нет потери precision). **H1/H2/H3/H4/H5 FAIL** — качество не изменилось. + +**Вывод:** RU↔EN синонимы нейтральны: добавленные варианты дают те же top-5, RRF +схлопывает дубликаты. Словарь оставлен как утилита (флаг off), но не рычаг качества. +Артефакты: `experiments/e40_ru_synonyms/`. + + +## [2026-10-25] — E39 pilot: Qwen3-Reranker-0.6B — 29–55 s, качество хуже; rejected + +**Вопрос:** даёт ли code-aware Qwen3-Reranker-0.6B выигрыш на 16-запросном пилоте +(каждый 3-й из E30), corrected index. + +**Вход:** `QUERIES_PILOT.jsonl` sha `3a8e6581…`; Qwen3-Reranker-0.6B-seq-cls ONNX +(1.19 GB, fp16, CPU), prompt-формат из model card, sigmoid. + +**Сырой вывод:** +``` +demote h1=0.625 h5=0.688 mrr=0.646 p50=116ms +qwen3_top10 h1=0.500 h5=0.562 mrr=0.512 p50=28941ms +qwen3_top20 h1=0.438 h5=0.562 mrr=0.490 p50=54788ms +``` + +**Вердикты:** H1/H2/H3/H4/H5/H6 FAIL. Qwen3 не usable на CPU: 29–55 s; качество ниже +deterministic. Все протестированные reranker'ы (MiniLM, BGE-base, BGE-M3, Qwen3) проиграли. +Reranking — не рычаг; deterministic pipeline 0.688/0.771/84ms — потолок на этой панели. +Артефакты: `experiments/e39_qwen3_rerank/`. + + +## [2026-10-25] — E38: BGE-reranker-base — снова хуже deterministic (0.688 → 0.625) + +**Вопрос:** даёт ли BGE-reranker-base int8 (XLM-R cross-encoder, 279 MB) выигрыш +над deterministic demote на corrected-индексе. + +**Вход:** E30 48 запросов; demote ON, P3 off; BGE-base top10/top20; ONNX CPU. + +**Сырой вывод:** +``` +demote h1=0.688 h5=0.771 mrr=0.713 p50=84ms +bge_base_top10 h1=0.625 h5=0.708 mrr=0.658 p50=3139ms +bge_base_top20 h1=0.583 h5=0.688 mrr=0.627 p50=6028ms +``` + +**Вердикты:** H7 PASS (лучше MS MARCO MiniLM 0.417). **H1/H2/H3/H4/H5/H6 FAIL**. + +**Вывод:** generic cross-encoder снова out-of-domain для кода; deterministic +pipeline сильнее. Reranking не рычаг на этой панели. Остаётся только Qwen3-Reranker-0.6B +(1.2 GB ONNX, CPU 1–3 s) как пилот. Артефакты: `experiments/e38_code_aware_rerank/`. + + +## [2026-10-25] — E37: chunking 32/64/128 строк — качество не меняется + +**Вопрос:** улучшает ли line-granularity chunking (32/64/128 строк, 25% overlap) +fused retrieval на 48 реальных запросах после исправления symbol_name. + +**Вход:** E30-панель; corrected index 16,347 чанков; E33 demote ON, P3 off, без +эмбеддингов/реранкера. Temp SQLite/FTS5 stores на каждую нарезку. + +**Сырой вывод:** +``` +current h1=0.688 h5=0.771 mrr=0.713 p50=151ms +split32 h1=0.688 h5=0.771 mrr=0.713 p50=161ms +split64 h1=0.688 h5=0.771 mrr=0.713 p50=131ms +split128 h1=0.688 h5=0.771 mrr=0.713 p50=136ms +``` + +**Вердикт:** все плечи идентичны; H1/H2/H3/H4/H6 FAIL, H5 PASS. Нарезка не узкое +место: исходные чанки уже в основном ≤32 строк (13 992/16 347), а top-5 определяется +file-level ranking/candidate set. Артефакты: `experiments/e37_chunking/`. + +## [2026-10-25] — E36-audit: symbol_name был пуст; corrected baseline 0.688/0.771 + +**Root cause:** `write_text_records` брал symbol_name из `chunk_metadatas`, где его не было → +0/16 158 чанков. FTS5/BM25 boosts, exact-name и graph hydration были мертвы; E32–E35 +измеряли повреждённый индекс. + +**Fix:** AST-symbol→line-range mapping в `IndexPipeline.process_file`; reindex → +4 772/16 347 с symbol_name. Graph-stage hydration + relative paths + line fallback. +P3: now reads `get_file_head`; off by default (0 качества, +96 ms без reranker). + +**Corrected ablation:** full h1 0.688/h5 0.771/p50 183 ms; no_p3 87 ms; +no_bucket 0.562; no_o1 0.667; no_demote 0.625; minimal 0.542/0.750. +Главные рычаги — bucket weights, demote non-code, O1; P3 только с reranker. +Артефакты: `experiments/e36_ablation/`. + + +## [2026-10-24] — E35: маленький MS MARCO MiniLM rerank — out-of-domain, качество рушится + +**Вопрос:** даёт ли маленький ONNX cross-encoder (ms-marco-MiniLM-L-6-v2 int8, 23 MB) +лучший quality/latency, чем BGE-M3, на 48 реальных запросах. + +**Вход:** E30-панель sha `d4934cb7…`; E33 demote ON; модель ONNX CPU, 512 токенов. +Плечи: demote, minilm_top10, minilm_top20. + +**Сырой вывод:** +``` +demote h1=0.688 h5=0.729 mrr=0.705 p50=85ms +minilm_top10 h1=0.417 h5=0.646 mrr=0.513 p50=723ms +minilm_top20 h1=0.417 h5=0.542 mrr=0.469 p50=1405ms +``` + +**Вердикты:** H4 PASS (p50 <1s). **H1/H2/H3/H5/H6/H7 FAIL** — качество резко хуже demote. + +**Вывод:** MS MARCO MiniLM — web-passage модель, не code-aware; на коде рушит fused ranking. +Нужен code-aware reranker (Qwen3-Reranker-0.6B и т.п.) или recall-работа (chunking/query expansion). +Лучший результат остаётся E33 demote 0.688/0.729. Артефакты: `experiments/e35_small_rerank/`. + +## [2026-10-24] — E34b: сильные graph/PRF boosts — снова нулевой результат + +**Вход:** E30 48 запросов; graph ×1.5, PRF ×1.2, both. Все детерминированные. +**Сырой вывод:** все плечи h1 0.688 / h5 0.729 / MRR 0.705 / p50 93–94ms — идентично. +**Вывод:** усиление boost не помогает: добавленные кандидаты не gold или дубликаты. +Детерминированное расширение пула исчерпано на этой панели. Артефакты: `experiments/e34b_boost/`. + + +## [2026-10-24] — E34: graph expansion + PRF — нулевой результат (top-5 не изменился) + +**Вопрос:** улучшают ли детерминированные PRF (top-1 symbol) и graph expansion +(callers/callees top-3) fused retrieval на 48 реальных запросах, поверх E33 demote. + +**Вход:** E30-панель sha `d4934cb7…`; SQLite 16 158 чанков, граф 13 744 узла. +Плечи: demote, demote_prf, demote_graph, demote_graph_prf. + +**Сырой вывод:** +``` +demote h1=0.688 h5=0.729 mrr=0.705 p50=119ms +demote_prf h1=0.688 h5=0.729 mrr=0.705 p50=108ms +demote_graph h1=0.688 h5=0.729 mrr=0.705 p50=108ms +demote_graph_prf h1=0.688 h5=0.729 mrr=0.705 p50=114ms +``` + +**Вердикты:** H5 PASS (p50 <300ms). **H1/H2/H3/H4/H6/H7 FAIL** — все метрики идентичны. + +**Вывод:** добавленные PRF/graph-кандидаты не вошли в top-5: их score (0.8×/0.6× top-1) +ниже существующего ядра. Детерминированное расширение пула само по себе не двигает top-5; +нужен ranker или сильный boost. E33 demote остаётся единственным детерминированным +выигрышем. Следующий шаг — маленький cross-encoder (E35). Артефакты: `experiments/e34_graph_prf/`. + + +## [2026-10-24] — E33: детерминированный demote tests/docs даёт +3 hit@1 (0.625 → 0.688) + +**Вопрос:** какие детерминированные сигналы (без эмбеддингов/реранкера) улучшают +fused retrieval на 48 реальных запросах. + +**Вход:** E30-панель sha `d4934cb7…`; SQLite 16 158 чанков, граф 13 744 узла. +Плечи: baseline, FTS5 field weights, +demote non-code, +caller prior/exact-name. + +**Сырой вывод:** +``` +baseline h1=0.625 h5=0.708 mrr=0.657 p50=155ms +fts_weights h1=0.625 h5=0.708 mrr=0.657 p50=103ms +fts_weights_demote h1=0.688 h5=0.729 mrr=0.705 p50=97ms +fts_weights_demote_prior h1=0.667 h5=0.708 mrr=0.681 p50=98ms +``` + +**Вердикты:** H2 PASS (demote +3 h1), H5 PASS (p50 <300ms). +**H1 FAIL** (FTS5 weights нейтральны), **H3 FAIL** (caller prior хуже), +**H4 FAIL** (0.688 <0.70), **H6 FAIL** (не 95%). + +**Вывод:** `demote tests/docs/experiments/fixtures ×0.3` — реальный детерминированный +выигрыш: +3 hit@1 при p50 97 ms. FTS5 weights — нейтральны; caller prior — вреден. +Следующий шаг: добавить demote в прод под флагом, затем комбинировать с graph expansion +и маленьким cross-encoder. Артефакты: `experiments/e33_deterministic/`. + + +## [2026-10-24] — E32: code-aware BM25 (identifier split + field boosts) — h1 не вырос, h5 +1 + +**Вопрос:** улучшает ли code-aware BM25 (camelCase/snake_case split, field boosts +name×3/path×2, proper k1/b, stopwords) fused retrieval на 48 реальных запросах. + +**Вход:** E30-панель sha `d4934cb7…`; SQLite 16 158 чанков, граф 13 744 узла. +Плечи: baseline, code_b75, code_b45, code_b45_stop. Без эмбеддингов и реранкера. + +**Сырой вывод:** +``` +baseline h1=0.625 h5=0.729 mrr=0.662 p50=90ms +code_b75 h1=0.562 h5=0.708 mrr=0.620 p50=98ms +code_b45 h1=0.604 h5=0.729 mrr=0.651 p50=100ms +code_b45_stop h1=0.604 h5=0.750 mrr=0.655 p50=90ms +``` + +**Вердикты:** H2 PASS (b45 > b75 по h5), H4 PASS (p50 90–100 ms). +**H1 FAIL** (h1 не вырос), **H3 FAIL** (stopwords не дали h1), **H5 FAIL** (не 95%). + +**Вывод:** code-aware BM25 — нейтрален по h1; stopwords+b45 дают +1 h5 (0.750). +Узкое место — recall/ranking, не токенизация. Следующие: graph expansion, +query expansion/PRF, маленький cross-encoder, semantic anchors. +Артефакты: `experiments/e32_code_bm25/`. + + +## [2026-10-24] — E31: BGE-M3 cross-encoder rerank on 48 real queries (best h1 0.646, but 6.6–19.5 s) + +**Вопрос:** даёт ли cross-encoder BGE-M3 выигрыш над fused retrieval без хранения векторов. + +**Вход:** E30-панель 48 запросов, sha `d4934cb7…`; SQLite/FTS5 16 158 чанков, +PropertyGraph 13 744 узла. Конфиги: no_rerank, BGE top10/top30/top50. +`top50` не завершён — harness timeout 30 мин. + +**Сырой вывод:** +``` +no_rerank h1=0.625 h5=0.729 mrr=0.662 p50=107ms +BGE top10 h1=0.646 h5=0.771 mrr=0.698 p50=6577ms +BGE top30 h1=0.562 h5=0.812 mrr=0.662 p50=19521ms +``` + +**Вердикты:** H2 PASS (top30 h5 +4), H5 PASS (не 95%), H6 PASS (BGE > cosine 0.604). +**H1 FAIL** (top30 h1 хуже no_rerank), **H3 FAIL** (p50 19.5 s), **H7 FAIL** (top10 ≠ top30). +H4 ⏳ (top50 timeout). + +**Вывод:** BGE top10 даёт лучший h1 (0.646), но p50 6.6 s; top30 даёт h5 0.812, но h1 0.562 +и 19.5 s. 95% не достигнуто (max h1 0.646, h5 0.812). BGE-M3 Q4 слишком тяжёл для CPU; +следующий шаг — маленький ONNX cross-encoder (bge-reranker-base / MiniLM) + усиление recall +(code-aware BM25, exact-name, graph/query expansion). Артефакты: `experiments/e31_bge_rerank/`. + + +## [2026-10-24] — E30: production-like on-demand rerank on 48 real queries (on-demand rerank does NOT beat fusion) + +**Вопрос:** даёт ли on-demand rerank (embeddinggemma-2 / e5) выигрыш в качестве +на реальных запросах проекта при текущем SQLite/FTS5 retrieval, и какой ценой. + +**Вход (заморожен ДО прогона):** `experiments/e30_prod_rerank/frozen/QUERIES.jsonl`, +sha256 `d4934cb7875bd239745fd935dcdef4a6e3e206781b400303c677505de56d9fac`, +48 запросов: 33 из E28 + 15 свежих docstring-запросов. Гипотезы — `frozen/HYPOTHESES.md` +(5 из 8 ожидались к провалу). Модели: Q8 (309 MB), Q4 UD-Q4_K_XL (176 MB), e5 Q8 (132 MB). +Индекс: 16 158 чанков / 1 473 файла, граф 13 744 узла / 45 455 рёбер. + +**Сырой вывод (один прогон; ключевые конфиги — два):** +``` +no_rerank (clean) h1=0.625 h5=0.729 mrr=0.662 p50=109ms +e5 384 top10 h1=0.354 h5=0.729 mrr=0.500 p50=912ms +Q8 768 top10 h1=0.604 h5=0.729 mrr=0.653 p50=4285ms +Q8 256 top10 h1=0.583 h5=0.729 mrr=0.646 p50=2630ms +Q4 768 top10 h1=0.562 h5=0.729 mrr=0.632 p50=1867ms +``` +Повтор Q8/Q4/e5/no_rerank: качество идентично, p50 109 / 1148 / 5141 / 3872 ms. + +**Вердикты:** H2 PASS (MRL 256), H5 PASS (e5 ≥2× быстрее), H4/H6/H8 FAIL как ожидалось. +**H1 FAIL** (Q4 vs Q8 = 2/48), **H3 FAIL** (top20 не меняет h5), **H7 FAIL** — +`no_rerank` hit@1 0.625 > Q8 0.604: on-demand rerank не даёт выигрыша, стоит ~40× latency. + +**Вывод:** на этой панели fused retrieval лучше on-demand rerank по hit@1 и в 40 раз +быстрее. Держать `MSCODEBASE_ONDEMAND_RERANK` выключенным до панели >100 запросов; +если rerank нужен — Q8 768 (0.604, 4–5 с) или Q8 256 (0.583, 2.6 с). Это **не** +отменяет E25 (другой пул/индекс), но отменяет перенос E25-вывода на прод-панель. + +**Артефакты:** `experiments/e30_prod_rerank/{SUMMARY.md,frozen/,results/}`. + ## [2026-10-10] — EXP-29 разрез symbol/NL: закрывает H5, корректирует число EXP-28 (PARTIAL) **Зачем:** EXP-28 оставил H5 как «⏳ не проверено» — в панели не было разметки symbol-vs-NL. @@ -3142,3 +3685,8 @@ GATE-примеры (verbatim): OK — «FreshnessChecker проверяет а **Вывод:** E26-символы уже сняли сливки; 0.6B-описания избыточны (mrr +0.006 — микроскопический рескоринг без перестановок). Doc2Query в этом виде — НЕТ. + +**E56 follow-up (implemented):** `search_docs` language-dedupe (ru/en/zh -> one +doc, prefer query language); `SQLiteChunkStore.delete_files` + full-run prune in +`scripts/reindex_text_only.py`. Live docs check: exact 1/8, same-basename 6/8. +Tests: `test_search_docs.py` (3), `test_chunk_store_delete_files.py` (1). diff --git a/README.md b/README.md index 0879d25a..ea11d810 100644 --- a/README.md +++ b/README.md @@ -2,556 +2,347 @@ MSCodeBase Banner -[🇬🇧 English](README.md) • [🇷🇺 Русский](docs/ru/README.md) • [🇨🇳 中文](docs/zh/README.md) +[English](README.md) · [Русский](docs/ru/README.md) · [中文](docs/zh/README.md) # MSCodebase Intelligence -**AI-powered semantic code search for Zed IDE — deep code analysis MCP server** +**Local-first code intelligence for Zed: lexical search, call graph, impact analysis, project memory, and safe write tools — exposed over MCP.** [![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) [![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-2131%20passed-brightgreen)](tests/) -[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-65-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) - -*Last updated: 2026-09-23* +*Last updated: 2026-10-25. The English page is authoritative; RU/ZH mirrors may lag.* --- -## 🎯 Positioning - -**MSCodeBase Intelligence** is an MCP server for **Zed IDE** that gives AI assistants **deep understanding of the entire codebase**: semantic search, call graph, project memory, diagnostics. - -This is **not** an LSP server or a replacement for the editor's built-in autocomplete. It's a "code intelligence" layer on top of the editor: - -``` -┌─────────────────────────────────────────────────────┐ -│ Zed IDE │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ LSP (built-in autocomplete, │ │ -│ │ inline hints, diagnostics) │ │ -│ └───────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ MSCodeBase (MCP server) │ │ -│ │ · Semantic search across the codebase │ │ -│ │ · Call graph & impact analysis │ │ -│ │ · Project memory (ADR, tech debt) │ │ -│ │ · Self-diagnostics and self-healing │ │ -│ │ · 65 tools for AI assistant │ │ -│ └───────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ -``` - -### What you get - -| Feature | MSCodeBase | Standard LSP (pyright/pylsp) | -|---------|:----------:|:---------------------------:| -| 🔍 **Semantic search** (BM25 + Vector + Reranker) | ✅ | ❌ | -| 🧠 **Call graph + impact analysis** | ✅ | ❌ | -| 🗃️ **Project memory** (ADR, known issues) | ✅ | ❌ | -| 🏥 **Self-diagnosis + self-healing** | ✅ | ❌ | -| 🔎 **Cross-repo search** | ✅ | ❌ | -| 🤖 **RAG answer generation** (mode=ask) | ✅ | ❌ | -| 🔬 **Search explainability** (per-stage score trace) | ✅ | ❌ | -| 🏛️ **Architecture drift detection** (chain/circular/hub) | ✅ | ❌ | -| ✅ **Claim verification** (agent fact-checking vs code) | ✅ | ❌ | -| ✏️ **Inline autocomplete** | ❌ | ✅ | -| 🏷️ **Inlay hints** | ❌ | ✅ | - -### LSP: Hybrid Rename Only - -MSCodeBase **uses LSP only for `codebase(action="rename")`** — the LSP client (`src/core/lsp_client.py`) spawns **pyright-langserver** for precise cross-file rename, with graceful fallback to SymbolIndex (Tree-sitter) on timeout. All other functionality is implemented through **65 MCP tools**. - -The standalone LSP server (`src/lsp_main.py`) was **removed** — see [LSP_WONTFIX.md](docs/en/investigations/LSP_WONTFIX.md). - -### Platforms - -Designed and tested on **Windows**. macOS and Linux should work but have not been validated officially. - -### Languages - -| Language | Parsing | Call Graph | Data Flow (ASSIGNED_FROM) | -|---|---|---|---| -| **Python** | ✅ | ✅ | ✅ | -| **TypeScript** | ✅ | ✅ | ✅ | -| **TSX** | ✅ | ✅ | ✅ | -| **Rust** | ✅ | ✅ | ✅ | -| **Go** | ✅ | ✅ | ✅ | -| **JavaScript** | ✅ | ✅ | ✅ | -| **Java** | ✅ | ✅ | ✅ | -| **C#** | ✅ | ✅ | ✅ | -| **Ruby** | ✅ | ✅ | ✅ | -| **PHP** | ✅ | ✅ | ✅ | -| **Kotlin** | ✅ | ✅ | ✅ | -| **Swift** | ✅ | ✅ | ✅ | -| **C** | ✅ | ✅ | ✅ | -| **C++** | ✅ | ✅ | ✅ | -| **Scala** | ✅ | ✅ | ✅ | -| **Dart** | ✅ | ✅ | ✅ | -| **Shell / Bash** | ✅ | ✅ | ❌ (grammar without RHS-field) | -| **SQL** | ✅ (context) | ❌ | ❌ | -| **YAML** | ✅ (context) | ❌ | ❌ | -| **TOML** | ✅ (context) | ❌ | ❌ | -| **HTML** | ✅ (context) | ❌ | ❌ | -| **CSS** | ✅ (context) | ❌ | ❌ | -| **HCL / Terraform** | ✅ (context) | ❌ | ❌ | - -## ✨ Features - -| Feature | Description | -|---------|-------------| -| 🔍 **Unified Search** | `search_code(query, mode, intent_hint)` — single tool: fast/quality/deep/context/ask/auto | -| 🧠 **Intelligence Layer** | 16 high-level `intel_*` tools: self-diagnostics, topology, memory, error prediction | -| 🌐 **Cross-repo Search** | Search across multiple projects with `@mention` syntax | -| 🌳 **Call Graph** | Full call graph: definition + callers + callees + impact analysis | -| 🏗 **Structural Search** | 13 AST patterns (class_inheritance, async_function, decorator, etc.) | -| 🔎 **Context Search** | Find similar code — paste a fragment, get semantic duplicates | -| 🪣 **Multi-Bucket RAG** | Code/docs buckets, soft weighting, intent_hint (code/docs/auto) | -| 🤖 **mode=ask** | RAG answer generation via phi-4 (server profile) | -| 💾 **LanceDB v2** | Vector DB with per-project isolation (incremental BM25 reindex) | -| 🛡 **Rate Limiting** | DebounceBatch + CircuitBreaker — protection against VFS loops | -| 🏥 **Self-Diagnosis** | `get_health_report` + `index_health` — full check and recovery | -| 🧪 **Clean Architecture** | DI Container (14 services), 65 tools (32 core + 16 intel + 13 inline + 4 dev), 2142 tests | -| 🪟 **Multi-Window** | `ProjectIndexerRegistry` — isolated Indexer per project, LRU 5, ResourceMonitor throttle | -| ✏️ **Write Tools** | `codebase(action=...)` — unified hub: rename, move, delete, replace, insert, ack | -| ⚡ **Meta-Patching** | LanceDB `move_chunks_metadata` — file_path rename without re-embedding (50ms vs 5s) | -| 🔗 **Data Flow Graph** | `ASSIGNED_FROM` edges track variable assignments. Unified Walker + Conditional Flow (if/for/while/try). 29 edge types in PropertyGraph. | -| ⚙️ **SYSTEM_PROFILE** | `light` (sync) / `server` (async with phi-4) | -| 🎯 **MMR Diversification** | Maximal Marginal Relevance (λ=0.6) after RRF — removes duplicates while preserving relevance. 0.3ms for 50 docs. | -| 🧠 **Auto Intent Detection** | Keyword-based auto-detection of code/docs intent from query text. No manual `intent_hint` required. | -| 📖 **Extended Synonyms** | 39 synonym groups (auth↔login, function↔method, cache↔buffer, etc.) — bridges the gap between user terminology and code. | - ---- - -## 🚀 Quick Start - -Install the `mscodebase-intelligence` extension in Zed, then: +## What this is + +MSCodebase Intelligence is a **local MCP server** that gives an AI assistant +working in Zed a grounded view of a codebase. It is deliberately not a chat +model and not a replacement for the editor's LSP. Its job is to answer: + +- Where is this symbol defined, who calls it, and what breaks if it changes? +- Which files are relevant to this question — in code, tests, or documentation? +- What did the project already decide, and which incidents are still open? +- Can a rename/move/replace be previewed and applied safely? + +The current default is **text-first retrieval**: SQLite FTS5/BM25 plus a +Tree-sitter/PropertyGraph index. Embedding and reranker providers exist, but +they are optional and are not required for the default path. This choice is +intentional: it keeps the server useful offline, keeps latency predictable, and +avoids making a vector database the single point of failure. + +## What works today + +| Area | What it does | Notes | +|---|---|---| +| Code search | BM25/FTS5 over parsed chunks; exact-symbol and path signals; optional graph expansion | `search_code` | +| Documentation search | Separate `docs/**` FTS channel, automatically selected when the BM25 margin is strong | No LLM router | +| Symbol intelligence | Definition, callers/callees, references, impact, structural search | `get_symbol_info`, `impact_analysis`, `structural_search` | +| Graph | PropertyGraph of files/symbols with call, import, assignment, decorator and override edges | Rebuild with `scripts/rebuild_graph.py` if empty | +| Project memory | ADRs, incidents, known issues, retraction/supersede lifecycle | `intel_*` tools | +| Safe writes | Rename, move, delete, replace, insert with preview/impact checks | `codebase(action=...)` | +| Diagnostics | Runtime passport, health report, index status, logs, job status | `debug_runtime_passport`, `get_health_report` | +| LSP rename | Basedpyright-backed cross-file rename with SymbolIndex fallback | Only for rename | + +## Search quality: measured, with its limits + +Quality claims are only useful with the measuring stick attached. The numbers +below come from an internal **48-query panel** (33 real project questions plus +15 docstring-derived questions), evaluated on a **clean index** with +`experiments/**` excluded and the graph present. They are not a universal +benchmark, and they do not describe every language or repository. + +| Path | hit@1 | hit@5 | What the number means | +|---|---:|---:|---| +| Code, quality mode | 0.825 | 0.900 | 33/40 code questions returned the correct file first | +| Docs, exact path | 0.000 | 0.125 | Gold points at `docs/en/X`; retrieval often returns `docs/ru/X` | +| Docs, same basename | 0.750 | 0.750 | Counting translations as the same document | +| Overall, quality mode | 0.688 | 0.750 | Code and docs mixed exactly as the panel is labeled | +| Overall, auto docs route | 0.792 | 0.875 | BM25-margin router + docs channel; translations normalized | +| Fast mode | 0.542 | 0.729 | Lower quality, lower latency; use for exact/simple lookups | + +What this does **not** mean: + +- It does **not** mean 95% accuracy. No static retrieval setup on this panel + reached that. A two-channel repo map (code PageRank + docs BM25) reached about + 40% / 62% / 73% gold-file coverage at 1k / 2k / 4k tokens with perfect routing; + PageRank alone was lower (about 33% / 55% / 68% on code queries). It is a + context-selection aid, not a search replacement. +- It does **not** mean every reranker improves things. Several local rerankers + were measured and rejected because they lowered hit@1 or added unacceptable + latency on this panel. +- It does **not** mean the docs path is broken. The docs channel often returns + the correct document in another language (`docs/ru/X` for `docs/en/X`). That + is a gold-labeling issue, not a retrieval failure. + +The panel, raw runs, hypotheses and summaries live under `experiments/`. Treat +them as a reproducible lab notebook, not as marketing copy. + +## How search works + +The default path is deterministic: + +1. **Parse and index** files into chunks and a PropertyGraph. +2. **Retrieve** candidates with SQLite FTS5/BM25, symbol/path signals, and + optionally graph neighbours. +3. **Rank and clean** the candidates: optional on-demand rerank, exact-name + boost, and symbol deduplication. RRF/MMR/dense fusion belong to the optional + LanceDB/vector path, not to every default query. +4. **Route docs cheaply** when the best docs score exceeds the best code score + by a configurable margin (`MSCODEBASE_DOCS_MARGIN`, default `4.0`). +5. **Return** ranked chunks/files with metadata. `intent="docs"` or + `intent="code"` overrides the automatic choice. + +There is no LLM in this routing step. The router is one extra FTS query and a +score comparison, which keeps it local, explainable and cheap. + +Optional layers: + +- Embedding providers (llama.cpp GGUF, ONNX, LM Studio/Ollama) for vector + candidates. +- On-demand reranking when a provider is configured. +- Graph expansion and PageRank-style repository ranking. + +When `MSCODEBASE_DENSE_OFF=true` (the current default), the server does not +require an embedding server to answer code or docs queries. + +## Quick start + +Requirements: Windows is the validated platform; Python 3.14+; Zed with the +`mscodebase-intelligence` extension installed. ```bash cd python install.py - -# Quick sync (code only, no prompts): +# or, for a code-only sync without prompts: python install.py --sync - -# CI mode (no prompts, fail fast): +# CI-style, no prompts: python install.py --yes - -# Skip model downloads: -python install.py --skip-models - -# Restart Zed (File → Quit → reopen) -# Verify: intel_get_runtime_status() ``` -**install.py does:** -1. Copies 39+ source files to the extension directory -2. Installs Python dependencies -3. Downloads llama-server.exe + GGUF reranker model (bge-reranker-v2-m3). The embedder (multilingual-e5-small INT8) is an ONNX model downloaded separately. -4. Configures MCP in Zed's settings.json - -See also: [AI_INSTALLATION_PROMPT.md](AI_INSTALLATION_PROMPT.md), [docs/en/INSTALL.md](docs/en/INSTALL.md) - -### Providers +`install.py` copies the server into the Zed extension directory, installs +dependencies, optionally prepares model binaries, and writes the MCP +configuration. Restart Zed after it finishes. -MCP auto-selects the best available provider (in priority order): +Verify from the agent panel: +```text +debug_runtime_passport() +intel_get_runtime_status() +codebase(action="index", path="status") ``` -llama.cpp GGUF (native, preferred) → ONNX INT8 (in-process fallback) → LM Studio (if running) → BM25 only - ~1.7 GB RAM (llama-server) ~0.5 GB RAM ~6 GB RAM no embeddings - e5-small GGUF (384dim) e5-small INT8 (384dim) external API -``` - -> Embedding runs via **llama.cpp** (`llama-server.exe`, preferred; ONNX in-process preload is canceled when llama.cpp is available). -> The reranker runs as a separate `llama-server.exe` process serving the BGE-M3 GGUF model. -> ONNX INT8 / LM Studio are fallback providers if llama.cpp is unavailable. - -Benchmarks: [docs/research/2026-07-10-final-benchmark.md](docs/research/2026-07-10-final-benchmark.md) - ---- - -## 📚 Documentation Map - -| Document | Description | Audience | Languages | -|----------|-------------|----------|-----------| -| **[docs/en/INSTALL.md](docs/en/INSTALL.md)** | Installation, setup, uninstall | Users | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/ARCHITECTURE.md](docs/en/ARCHITECTURE.md)** | Clean Architecture, Layers, DI | Developers | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/ARCHITECTURE_DEEP.md](docs/en/ARCHITECTURE_DEEP.md)** | Deep architecture: pipeline, lifecycle, comparison | Architects | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/SEARCH_PIPELINE.md](docs/en/SEARCH_PIPELINE.md)** | Search pipeline: BM25 → RRF → Reranker | Developers | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/GRACEFUL_DEGRADATION.md](docs/en/GRACEFUL_DEGRADATION.md)** | 5 levels of graceful degradation (llama.cpp → ONNX → BM25) | DevOps | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/ARCHITECTURE_LAYERS.md](docs/en/ARCHITECTURE_LAYERS.md)** | 10 runtime layers | Architects | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/FAQ.md](docs/en/FAQ.md)** | Frequently Asked Questions | All | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/TELEMETRY.md](docs/en/TELEMETRY.md)** | Metrics, ETA, data collection | DevOps | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/investigations/ONNX_SESSION_REPORT.md](docs/en/investigations/ONNX_SESSION_REPORT.md)** | Full ONNX migration, 7 fixes, benchmarks | Support | 🇬🇧 | -| **[docs/en/investigations/LSP_WONTFIX.md](docs/en/investigations/LSP_WONTFIX.md)** | LSP on Windows investigation (WONTFIX) | Support | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/ZED_WINDOWS_QUIRKS.md](docs/en/ZED_WINDOWS_QUIRKS.md)** | Windows specifics, Restricted Mode | Windows users | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/CHANGELOG.md](docs/en/CHANGELOG.md)** | Version history | All | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/CONTRIBUTING.md](docs/en/CONTRIBUTING.md)** | How to contribute, PRs | Contributors | 🇬🇧 🇷🇺 🇨🇳 | -| **[docs/en/SECURITY.md](docs/en/SECURITY.md)** | Security policy, vulnerabilities | Security | 🇬🇧 🇷🇺 🇨🇳 | -| **[AGENTS.md](AGENTS.md)** | AI Agent system rules | AI Agent | 🇬🇧 | -| **[SECURITY.md](SECURITY.md)** | Security policy, reporting vulnerabilities | Security | 🇬🇧 | -| **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** | Community standards | Contributors | 🇬🇧 | -| **[CONTRIBUTING.md](CONTRIBUTING.md)** | How to contribute (root-level) | Contributors | 🇬🇧 | -| **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)** | Known issues & technical debt registry | All | 🇬🇧 | - -All documents are cross-referenced. Available in 3 languages: English, Русский, 中文. - ---- - -## Research & Writeups -Deep-dives into specific technical findings from building this project: - -- [PageRank vs RAG on a Real Codebase: Corrected Numbers, and What I Almost Got Wrong Twice](https://dev.to/mansio/i-measured-pagerank-token-savings-on-a-real-codebase-the-result-will-surprise-you-5bnj) — comparing retrieval methods, with two rounds of honest self-correction -- [I Asked One AI to Fact-Check Another AI's Audit of My Own Code](https://dev.to/mansio/i-asked-one-ai-to-fact-check-another-ais-audit-of-my-own-code-1ac3) -- [The Silent Vector Contamination Bug: Why Your Concurrent Embeddings Might Be Lying to You](https://dev.to/mansio/the-silent-vector-contamination-bug-why-your-concurrent-embeddings-might-be-lying-to-you-5fg7) - -## Recent results (Sept 2026) - -- **F5 4-arm unit-of-return:** A 16.3% / B 34.4% / C 97.5% / D 0%; code 50% vs 6.3% → [writeup](docs/blog/unit-of-return-4arm.md) -- **NodeRAG refuted** on the same bench: TF-IDF 80% vs graph BFS 70% -- **D-ablation floor test** (no-abstention): ~4% (2/48), majority 0/16 — prose B margin survives - ---- - -## 🔧 MCP Tools (65 total) - -> 65 registered = 32 core + 16 intel + 13 inline + 4 dev. With `execute_script` (registered when `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`) — 66. -> -> **Visibility:** the MCP client only sees tools in the `MSCODEBASE_MCP_TOOLS` allowlist (see `src/mcp/server_tools.py`). Some core tools listed below (`get_repo_map`, `get_repo_rank`, `get_hotspots`, `get_bug_correlation`, …) are **hidden by default** until added to the allowlist; `MSCODEBASE_MCP_TOOLS=""` shows all. - -### Core Search - -| Tool | When to Use | -|------|-------------| -| `search_code(query, mode, filter_layer, intent_hint)` | **Main search tool.** `mode="auto"` / `"fast"` / `"quality"` / `"deep"` / `"context"` / `"ask"`. `intent_hint="code"` / `"docs"` / `"auto"` — soft bucket weighting. `filter_layer="core"` — search within specific architecture layer | -| `structural_search(pattern)` | AST search: `class_inheritance`, `async_function`, `function_with_decorator` and more | -| `cross_repo_search(query @repo)` | Search across multiple projects (mono-repo) | -| `cross_project_deps(action)` | Cross-project dependency graph: `graph` / `deps` / `cycles` / `impact` | -| `get_symbol_info(query)` | Call Graph: callers, callees, impact files | -| `execute_script(code, timeout, args)` | **Sandboxed Python execution (3-layer).** AST validation + runtime `__import__` wrapper + subprocess isolation. Audit-logged. Returns `{stdout, stderr, exit_code, duration_ms, truncated, timed_out}` | -| `impact_analysis(symbol)` | Symbol change impact analysis (risk score, depth) | - -### LSP Analysis (basedpyright) - -| Tool | When to Use | -|------|-------------| -| `lsp_find_references(file_path, line, col, symbol_name)` | **Exact AST references** (all usages of a symbol) via Zed's bundled basedpyright. `line`/`col` are 0-based (LSP); `col=-1` auto-detects via `symbol_name` | -| `lsp_find_definition(file_path, line, col, symbol_name)` | Jump to symbol definition (declaration) — precise, language-server-grade | -| `lsp_document_symbols(file_path)` | File structure tree: classes/functions/variables with positions | - -### Index Management (via `codebase(action="index", ...)`) - -| Action | When to Use | -|------|-------------| -| `codebase(action="index", path="status")` | Index status: chunks, files, symbols (`get_index_status`) | -| `codebase(action="index", path="progress")` | Indexing progress (phase, percent) | -| `codebase(action="index", path="project_dir", project_root=...)` | Start full project indexing (`index_project_dir`; prefer async `intel_trigger_reindex`) | -| `codebase(action="index", path="timeline")` | Indexing history by date | -| `codebase(action="index", path="health")` | Index diagnostics and self-recovery (`index_health`) | -| `notify_change(file_path)` | Force index update for a file (via DebounceBatch) — inline tool | -| `generate_chunk_summaries(root)` | LLM-generated descriptions for code chunks | -| `scan_changes(project_root)` | Architectural diff — analyze changes since last baseline | - -### System & Diagnostics - -| Tool | When to Use | -|------|-------------| -| `get_health_report()` | **Full self-diagnosis:** index, embedder, logs, synchronization | -| `get_logs(project_root)` | Latest errors and warnings from project logs | -| `read_live_file(path)` | Read file from LSP memory (including unsaved changes) | - -### Analytics - -| Tool | When to Use | -|------|-------------| -| `get_hotspots(project_root)` | Hotspots — files with high bug rate | -| `get_repo_rank(project_root, top_k)` | Symbol importance ranking (PageRank on call graph) | -| `get_bug_correlation(project_root)` | Bug-change correlation analysis | -| `get_repo_map(project_root)` | Project map: file tree + key symbols | -| `graph_query(action="related", target=path)` | Files related via co-change / bug correlation (via `related` action) | -| `graph_query(action, target)` | Graph queries: `impact` / `feature` / `deps` / `tests` / `cypher` / `flow` / `drift` / `verify` | -| `find_similar_bugs(error)` | Find similar bugs from history by error text | - -### Git & History (via `codebase(action="git", ...)`) - -| Action | When to Use | -|------|-------------| -| `codebase(action="git", path="log", ...)` | Semantic commit history (`get_commit_history`) | -| `codebase(action="git", path="history", ...)` | Change history for a specific file | -| `codebase(action="git", path="branch")` | Branch info + index status (`get_branch_info`) | - -### Lifecycle & Verification - -| Tool | When to Use | -|------|-------------| -| `submit_background_task(type, root)` | Run long tasks: `bug_correlation` / `build_knowledge_graph` / `full_analysis` | -| `get_task_status(task_id)` | Background task status | -| `verify_action(action_type)` | Verification: `file_write` / `git_commit` / `git_push` / `index_sync` | - -### Write Tools — `codebase(action=...)` - -| Action | When to Use | -|--------|-------------| -| `codebase(action="rename", old, new, apply)` | Rename symbol across all files (preview/apply, collision check) | -| `codebase(action="move", symbol, to_file, apply)` | Move symbol to another file (preview/apply, import updates) | -| `codebase(action="safe_delete", symbol, force, apply)` | Safe delete with reference check (force mode) | -| `codebase(action="replace", symbol, new_code, apply)` | Replace function/class body (preview/apply) | -| `codebase(action="insert_before", anchor, new_code, apply)` | Insert code before anchor symbol (preview/apply) | -| `codebase(action="insert_after", anchor, new_code, apply)` | Insert code after anchor's body (preview/apply) | -| `codebase(action="ack_impact", file_path)` | Acknowledge impact for modification guard | - -### Intelligence Layer (intel_*) — 16 High-Level Tools - -| Tool | What it does | -|------|-------------| -| `intel_get_runtime_status()` | Aggregated health status: embedder, index, resource usage | -| `intel_trigger_reindex()` | Fire-and-forget reindexing (does not block Zed) | -| `intel_get_job_status(job_id)` | Background task progress | -| `intel_code_topology(symbol)` | Call graph + module topology (< 2 sec) | -| `intel_get_project_memory()` | Project memory map: ADR, known_issues, tech_debt | -| `intel_log_incident(...)` | Log an incident to project history | -| `intel_analyze_incident(error)` | Find similar incidents + ready-made solutions | -| `intel_add_memory_node(section, data)` | Add a record to project memory | -| `intel_get_hotspots()` | Top-5 files with highest bug load | -| `intel_predict_root_cause(error)` | Predict root cause from logs + history | -| `intel_get_telemetry(days)` | Per-tool telemetry, resource usage, LLM stats | -| `intel_auto_collect_adrs(max_commits)` | Auto-generate ADRs from commit history | -| `intel_reset_index()` | Delete and rebuild index from scratch | -| `intel_retract_memory_node(node_id, reason)` | Retract a memory node (ACTIVE/VERIFIED → REFUTED, reason required) | -| `intel_restore_memory_node(node_id, reason)` | Restore a memory node from REFUTED (manual return, ADR-0002/0003) | -| `intel_supersede_memory_node(node_id, reason, new_node_id)` | Mark a node as SUPERSEDED — replaced by a newer fact | - -> `intel_tool_health()`, `intel_explain_project_state()`, `intel_get_project_context()` — see Diagnostic Tools below. - -### Dev Tools (4) - -| Tool | What it does | -|------|-------------| -| `generate_docs(project_root)` | Generate Markdown docs from PropertyGraph (DEPRECATED — use auto_update_docs) | -| `bump_version(project_root, part, dry_run)` | Bump project version + update CHANGELOG | -| `auto_update_docs(project_root, action)` | Auto-update documentation: update/check | -| `install_git_hooks(project_root, action)` | Install pre-commit hooks: install/uninstall/status | - -### Diagnostic Tools (7) - -| Tool | What it does | -|------|-------------| -| `debug_runtime_passport()` | Process passport: RUN_ID, PID, build info | -| `get_runtime_counters()` | Runtime counters: calls, blocks, warnings | -| `intel_execution_timeline(limit)` | Recent action timeline with durations | -| `intel_get_project_context(root)` | Single snapshot: state, index, health, memory | -| `intel_explain_project_state(root)` | Human-readable project state diagnosis | -| `intel_tool_health()` | Tool success rates, latency, confidence | -| `refresh_db_connection()` | Reset database handle and reconnect | - ---- +If the graph is empty but chunks exist, rebuild only the graph (no vectors): -## 🏗️ Architecture - -### Clean Architecture with DI Container - -``` -┌────────────────────────────────────────────────────────────────┐ -│ MCP Server (~1000 lines) │ -│ src/mcp/server.py + server_tools.py + server_factory.py │ -│ │ -│ ┌───────────────────────────────────────────────────────┐ │ -| │ DI Container (18 services) │ │ -│ │ src/core/di_container.py — ServiceCollection │ │ -│ │ │ │ -│ │ ┌──────────┐ ┌────────────┐ ┌───────────────────┐ │ │ -│ │ │ Indexer │ │ Searcher │ │ DebounceBatch │ │ │ -│ │ │ Embedder │ │ SymbolIdx │ │ CircuitBreaker │ │ │ -│ │ │ Parser │ │ FileGuard │ │ RateLimiter │ │ │ -│ │ └──────────┘ └────────────┘ └───────────────────┘ │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────┴─────────────┐ │ -│ ▼ ▼ │ -│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ -│ │ 32 Tool Classes │ │ 16 intel_* + 13 inline tools │ │ -│ │ src/mcp/tools/*.py │ │ intelligence/layer.py + │ │ -│ │ + codebase hub │ │ server_tools.py (inline) │ │ -│ │ Constructor Inj. │ │ error_boundary decorator │ | -│ │ 1 execute_script │ │ asyncio.wait_for(timeout) │ │ -│ └─────────────────────┘ └─────────────────────────────────┘ │ -└────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────┐ ┌────────────────────┐ -│ RemoteEmbedder │ │ LanceDB v2 │ -│ (llama.cpp GGUF │ │ (Vector DB) │ -│ native, primary; │ │ BM25 + Vector │ -│ ONNX INT8 │ │ │ -│ in-process │ │ │ -│ fallback) │ │ │ -└────────────────────┘ └────────────────────┘ -``` - ---- - -## ⚡ Performance - -| Mode | Latency | Best For | -|:-----|:--------|:---------| -| `search_code(query, mode="fast")` | ~80-500ms | Simple keyword / exact name | -| `search_code(query, mode="quality")` | ~250-2000ms | Semantic search with reranker | -| `search_code(query, mode="deep")` | ~2-5s | Complex research across modules | -| `search_code(query, mode="context")` | ~200-800ms | Find similar code by fragment | -| `get_symbol_info(query)` | ~200-1500ms | Symbol definition + call graph | -| `impact_analysis(symbol)` | ~1-5s | Change impact analysis | - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `LM_STUDIO_HOST` | `localhost` | LM Studio hostname | -| `LM_STUDIO_PORT` | `1234` | LM Studio port | -| `OLLAMA_HOST` | `localhost` | Ollama hostname | -| `OLLAMA_PORT` | `11434` | Ollama port | -| `LOG_LEVEL` | `INFO` | Logging verbosity level | -| `MSCODEBASE_MCP_TOOLS` | *(default set)* | Comma-separated list of visible tools (e.g. `search_code,codebase`) | -| `MSCODEBASE_EXECUTE_SCRIPT_ENABLED` | `false` | Enable `execute_script` tool (RCE risk) | -| `LLAMA_BACKEND` | `auto` | Reranker backend: `auto` / `msvc` (CPU) / `vulkan` (GPU) | -| `MSCODEBASE_REMOTE_TOKEN` | *(empty)* | Bearer token for remote mode (`src/remote_main.py`, Streamable HTTP). Empty = auth disabled | -| `MSCODEBASE_REMOTE_RATE_LIMIT_RPS` | `30.0` | Remote gate rate limit (requests/sec per key: per-token + per-IP). `0` = disabled | - -> `EMBEDDING_MODEL` (ранее в таблице) — больше не используется: модель определяется автоматически (llama.cpp GGUF, fallback ONNX e5-small INT8). - ---- - -## 🔧 Troubleshooting - -### MCP Server Not Responding - -**Symptoms:** tools timeout, no response. - -**Checklist:** -1. **File → Quit** → reopen the project -2. Run `python install.py` to reconfigure -3. Check logs: `%LOCALAPPDATA%\mscodebase\logs\` (data_root) - -### Index Empty (0 chunks) - -Run in Agent Panel: -``` -intel_trigger_reindex() +```bash +python scripts/rebuild_graph.py ``` -Then verify: `codebase(action="index", path="status")` - -### LM Studio Connection Issues +If the index is stale or missing, rebuild text chunks and graph: ```bash -# Verify the server responds: -python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:1234/v1/health').read())" +python scripts/reindex_text_only.py --project . ``` -Expected: `{"status":"ok"}`. - ---- - -## 📁 Project Structure - -``` -mscodebase-intelligence/ -├── src/ -│ ├── main.py # MCP server entry point (~194 lines) -│ ├── mcp/ -│ │ ├── server.py # MCP server creation (~597 lines) -│ │ ├── server_factory.py # DI setup + server lifecycle (~478 lines) -│ │ ├── server_tools.py # Tool registration + 13 inline tools (~607 lines) -│ │ └── tools/ # 17 modules + base class -│ │ ├── codebase_tool.py # codebase(action=...) hub + execute_script -│ │ ├── search_tools.py # search_code, get_symbol_info, impact_analysis -│ │ ├── indexing_tools.py # notify_change, index_project_dir, index_health -│ │ ├── git_tools.py # get_branch_info, get_commit_history, get_file_history -│ │ ├── system_tools.py # get_index_status, get_health_report, read_live_file, get_logs -│ │ ├── analysis_tools.py # structural_search, get_repo_map, get_repo_rank, scan_changes -│ │ ├── graph_tools.py # cross_repo_search, cross_project_deps, graph_query -│ │ ├── investigation_tools.py # get_bug_correlation, get_hotspots, find_similar_bugs -│ │ ├── lifecycle_tools.py # submit_background_task, get_task_status, verify_action -│ │ ├── meta_tools.py # IndexTool, GitTool, SystemTool (spoke tools for codebase hub) -│ │ └── write_tools.py # WriteTool (rename, move, delete, replace, insert) -│ ├── core/ # Business logic + backward-compat shims -│ │ ├── di_container.py # ★ DI Container (18 services, ServiceCollection) -│ │ ├── error_handler.py # error_boundary decorator + ToolError -│ │ ├── rate_limiter.py # SlidingWindowRateLimiter + DebounceBatch + CircuitBreaker -│ │ ├── graph.py # PropertyGraph (29 edge types) -│ │ ├── structural_search.py # 13 AST patterns (Tree-sitter) -│ │ ├── lsp_client.py # Thin LSP client (pyright JSON-RPC 2.0) -│ │ ├── intelligence_layer.py # Shim → core/intelligence/layer.py -│ │ ├── indexing/ # 18 files: indexer, parser, symbol_index, file_guard, ... -│ │ ├── search/ # 18 files: engine (Searcher), scoring, bm25, cypher_*, ... -│ │ └── intelligence/ # 5 files: layer (intel_* tools), jobs, health, context, store -│ ├── providers/ -│ │ ├── embedder/ -│ │ │ └── remote_embedder.py # ONNX e5-small INT8 + LM Studio/Ollama fallback -│ │ └── reranker/ # llama_runner, multi_provider, search_result_reranker, scoring -│ ├── config/ -│ │ └── settings.py # All configuration via os.getenv (Single Source of Truth) -│ └── utils/ # paths, i18n, ui_formatter, zed_config -├── docs/ -│ ├── en/ # English docs -│ ├── ru/ # Russian docs -│ └── zh/ # Chinese docs -├── scripts/ # CLI utilities (install, sync, benchmark, audit) -├── tests/ # 853 tests (pytest) -├── install.py # Installer (3 languages: en/ru/zh) -└── README.md +Then restart the MCP server/Zed so the new process opens the rebuilt stores. + +## Tools + +The server registers **64 tools** in four groups; `execute_script` adds one +optional tool when `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. The default +allowlist exposes **49** of them; hidden tools can be enabled with +`MSCODEBASE_MCP_TOOLS` (empty string shows all). The exact visible set is +reported in the MCP startup log and by `intel_get_runtime_status`. + +| Group | Examples | Purpose | +|---|---|---| +| Search and analysis | `search_code`, `plan_search`, `get_symbol_info`, `impact_analysis`, `structural_search`, `graph_query` | Find code, symbols, relationships, and change impact | +| Index and system | `codebase(action="index"...)`, `notify_change`, `read_live_file`, `get_health_report`, `get_logs` | Keep the index fresh and diagnose it | +| Intelligence | `intel_get_runtime_status`, `intel_get_project_memory`, `intel_log_incident`, `intel_analyze_incident`, `intel_predict_root_cause` | Project memory, incidents, runtime state | +| Write and lifecycle | `codebase(action="rename"...)`, `move`, `safe_delete`, `replace`, `insert_before/after`, `ack_impact` | Preview and apply safe source changes | +| Dev | `bump_version`, `auto_update_docs`, `install_git_hooks`, `generate_docs` | Repository maintenance | + +The `codebase` tool is a hub: its `action` selects index, git, or write +operations. See the tool schema in the MCP client rather than relying on a +hand-written list that can drift. + +## Architecture + +```text +Zed + │ + ├── built-in LSP (autocomplete, diagnostics) + │ + └── MCP client + │ + ▼ + MSCodebase MCP server + ├── Search SQLite FTS5/BM25, symbol and path signals, optional graph + ├── Graph PropertyGraph: calls, imports, assignments, decorators, overrides + ├── Index parser → chunks → FTS5 + graph; freshness checks + ├── Memory ADRs, incidents, known issues, retract/supersede + ├── Writes rename/move/delete/replace/insert with impact guards + ├── Diagnostics runtime passport, health, jobs, logs, telemetry + └── Optional embedding providers, reranker, LSP rename ``` ---- - -## 🛠️ Development - -See [docs/en/CONTRIBUTING.md](docs/en/CONTRIBUTING.md) for: -- How to add new MCP tools -- Test structure and CI pipeline -- Commit message conventions +The canonical data locations are outside the repository: + +- `%LOCALAPPDATA%/mscodebase/projects//` — per-project graph, memory, + job state. +- `%LOCALAPPDATA%/mscodebase/logs/` — server logs. +- `/.mscodebase/chunks.db` — text chunk store used by the Phase 3 + search path. + +LanceDB remains part of the legacy/optional vector path; it is not required by +the default text-first configuration. + +## Configuration + +All settings are environment variables with defaults in +`src/config/settings.py`. The ones that matter most for search: + +| Variable | Default | Effect | +|---|---|---| +| `MSCODEBASE_DENSE_OFF` | `true` | Skip dense/vector retrieval; use FTS5, symbols, graph | +| `MSCODEBASE_SQLITE_CHUNKS` | `true` | Use the SQLite/FTS5 chunk store | +| `MSCODEBASE_DEMOTE_NON_CODE` | `false` | Down-weight tests/docs/experiments in the fused pool | +| `MSCODEBASE_DEMOTE_INTENT_AWARE` | `false` | Keep docs when the query is classified as docs | +| `MSCODEBASE_DOCS_MARGIN` | `4.0` | Auto docs routing threshold (docs score − code score) | +| `MSCODEBASE_ONDEMAND_RERANK` | `true` | Optional live rerank; requires a provider | +| `MSCODEBASE_P3_ANCHOR` | `false` | Module-head anchors; measured to add latency without a reranker | +| `MSCODEBASE_MCP_TOOLS` | default set | Tool allowlist; empty means all registered tools | + +`experiments/**` is intentionally excluded from indexing. It contains frozen +panels and run artifacts; indexing them previously made BM25 return the panel +itself instead of the code. The files remain tracked in git. + +## Supported languages + +Python is the primary, fully exercised language. Tree-sitter parsing covers +JavaScript, TypeScript/TSX, Rust, Go, Java, C#, Ruby, PHP, Kotlin, Swift, C, +C++, Scala and Dart. SQL, YAML, TOML, HTML, CSS and HCL/Terraform are indexed +as context, without the same call-graph coverage. Graph and data-flow depth +depend on the grammar; not every language exposes the same edge types. + +## Limitations and known issues + +These are deliberate, documented boundaries rather than hidden behavior: + +- **Docs gold vs language:** the docs channel may return `docs/ru/X.md` when + the evaluation gold is `docs/en/X.md`. The document is correct; the exact-path + metric is not. Normalize by basename when evaluating translations. +- **Auto docs routing is heuristic:** the BM25 margin is configurable and was + tuned on the 48-query panel. A code query with strong documentation words can + still be routed to docs; `intent="code"` is the escape hatch. +- **Graph rebuild is separate from text reindex:** with + `MSCODEBASE_DENSE_OFF=true`, a full vector reindex is intentionally blocked. + Use `scripts/rebuild_graph.py` for the graph and + `scripts/reindex_text_only.py` for chunks. +- **No 95% claim:** static retrieval and the tested local rerankers did not + reach it on the internal panel. An LLM planner/agent loop is a different + experiment and is not the default path. +- **Fast mode is not quality mode:** it is faster but materially less accurate + on mixed natural-language questions. Use it for exact names and known paths. +- **Visible tools are allowlisted:** seeing "64 tools" in source does not mean + all 64 are exposed to a given client; check `MSCODEBASE_MCP_TOOLS`. + +## Reporting a bug + +A useful report for this project is a small experiment, not just a number. +Please include: + +1. The exact tool call and parameters (`query`, `mode`, `intent`, `limit`). +2. `debug_runtime_passport()` output and + `codebase(action="index", path="status")`. +3. Whether the graph was rebuilt (`scripts/rebuild_graph.py`) and whether + `MSCODEBASE_DENSE_OFF` / `MSCODEBASE_ONDEMAND_RERANK` were enabled. +4. The expected file and the actual returned file(s), plus whether the match is + exact-path or same-basename (translations count as the same document). +5. A minimal query that reproduces it, and the relevant log tail from + `%LOCALAPPDATA%/mscodebase/logs/`. + +This lets us separate four different failures that look alike: bad ranking, +missing candidate, stale/empty index, and a gold-label mismatch. + +## Project structure + +```text +src/ + main.py MCP entry point + mcp/ + server.py server creation + server_factory.py DI and lifecycle + server_tools.py tool registration and inline tools + tools/ search, index, graph, memory, write, lifecycle tools + core/ + search/ engine, FTS5/BM25, scoring, query plan, graph adapter + indexing/ parser, chunk store, index pipeline, file guard + intelligence/ project memory, incidents, health, jobs + graph.py PropertyGraph + di_container.py service collection + config/settings.py environment-backed settings + providers/ optional embedders and rerankers +experiments/ frozen panels, run artifacts, summaries +scripts/ install/reindex/graph/smoke utilities +tests/ pytest suite +docs/ architecture, installation, operations, research +``` -### Quick Start for Devs +## Development ```bash -# Setup python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt -# Run MCP server directly (test) -python -m src.main +# Focused search tests +pytest tests/test_search_docs.py tests/test_query_plan.py \ + tests/test_experiment_output_guard.py tests/test_searcher.py -q -# Run tests +# Full suite (integration/benchmark tests are selected by markers) pytest tests/ -m "not integration and not benchmark" -# Live smoke (реальные сервисы, без моков — обязательно после изменений в серверах/индексе) +# Live smoke: real services, no mocks python scripts/smoke_e2e.py --project . - -# Live smoke памяти (негативный контроль verify-on-read: VERIFIED/REFUTED/терминальные guard'ы) -python scripts/smoke_memory.py ``` ---- +The repository uses Conventional Commits, keeps experiment inputs under +`experiments/**/frozen/`, and records results in `EXPERIMENTS_LOG.md`, +`AGENT_DIARY.md` and `KNOWN_ISSUES.md`. -## 📄 License +## Documentation map -MIT License — see [LICENSE](LICENSE) for details. +| Document | What it covers | +|---|---| +| [docs/en/INSTALL.md](docs/en/INSTALL.md) | Installation, uninstall, platform notes | +| [docs/en/ARCHITECTURE.md](docs/en/ARCHITECTURE.md) | Layers, DI, module boundaries | +| [docs/en/ARCHITECTURE_DEEP.md](docs/en/ARCHITECTURE_DEEP.md) | Pipeline and lifecycle in depth | +| [docs/en/SEARCH_PIPELINE.md](docs/en/SEARCH_PIPELINE.md) | Retrieval, fusion, scoring, rerank | +| [docs/en/GRACEFUL_DEGRADATION.md](docs/en/GRACEFUL_DEGRADATION.md) | Behavior when providers are unavailable | +| [docs/en/ZED_WINDOWS_QUIRKS.md](docs/en/ZED_WINDOWS_QUIRKS.md) | Windows and Zed-specific behavior | +| [docs/en/CHANGELOG.md](docs/en/CHANGELOG.md) | Version history | +| [AGENTS.md](AGENTS.md) | Rules for AI agents working in this repository | +| [KNOWN_ISSUES.md](KNOWN_ISSUES.md) | Open debt and observations | +| [EXPERIMENTS_LOG.md](EXPERIMENTS_LOG.md) | Experiment hypotheses and raw results | ---- +## Research notes + +- [PageRank vs RAG on a real codebase](https://dev.to/mansio/i-measured-pagerank-token-savings-on-a-real-codebase-the-result-will-surprise-you-5bnj) +- [Fact-checking one AI's audit with another](https://dev.to/mansio/i-asked-one-ai-to-fact-check-another-ais-audit-of-my-own-code-1ac3) +- [The silent vector contamination bug](https://dev.to/mansio/the-silent-vector-contamination-bug-why-your-concurrent-embeddings-might-be-lying-to-you-5fg7) + +## License + +MIT. See [LICENSE](LICENSE). -## 🙏 Acknowledgments +## Acknowledgments -- [Zed IDE](https://zed.dev/) — code editor -- [LM Studio](https://lmstudio.ai/) — local LLM inference -- [LanceDB](https://lancedb.github.io/) — vector database -- [Model Context Protocol](https://modelcontextprotocol.io/) — MCP standard -# Env Access Extractor -Ported from codebase-memory-mcp (MIT, DeusData 2025). See `src/core/indexing/parser.py` (ENV_FUNCS_BY_LANG, ENV_MEMBERS_BY_LANG, _walk_env_accesses_iter) and `tests/test_env_extractor.py` (22 tests). +Zed, the Model Context Protocol, Tree-sitter, SQLite/FTS5, and the local +inference projects that make an offline-first assistant practical. diff --git a/docs/en/ARCHITECTURE.md b/docs/en/ARCHITECTURE.md index 3fdebaeb..2b1fb2b3 100644 --- a/docs/en/ARCHITECTURE.md +++ b/docs/en/ARCHITECTURE.md @@ -1,674 +1,251 @@ - + -[🇬🇧 English](ARCHITECTURE.md) • [🇷🇺 Русский](../ru/ARCHITECTURE.md) • [🇨🇳 中文](../zh/ARCHITECTURE.md) +[English](ARCHITECTURE.md) · [Русский](../ru/ARCHITECTURE.md) · [中文](../zh/ARCHITECTURE.md) # MSCodeBase Intelligence — Architecture Guide -> **Version:** 3.5.0 -> **Last updated:** 2026-08-12 -> **Architecture:** 4-Layer Architecture + Graph-Native PropertyGraph Layer + Data Flow Layer (Entry Points → MCP Server/DI → Tool Classes → Core Business Logic → PropertyGraph → Data Flow) with Multi-Window Registry + DocSync +> **Current as of 2026-10-25.** This document describes the default Phase 3 +> runtime: SQLite FTS5/BM25 plus PropertyGraph. Embeddings, reranking and the +> LanceDB vector path are optional and are called out explicitly. --- -## Table of Contents - -1. [Core Principles](#1-core-principles) -2. [Layer Architecture](#2-layer-architecture) -3. [DI Container (ServiceCollection)](#3-di-container) -4. [Tool Layer (32 core + 16 intel + 13 inline + 4 dev = 65 total)](#4-tool-layer) -5. [PropertyGraph Layer (v3.0)](#5-propertygraph-layer-v30) -6. [Cypher Query Engine (v3.0)](#6-cypher-query-engine-v30) -7. [Error Handling](#7-error-handling) -8. [Rate Limiting & Resilience](#8-rate-limiting--resilience) -9. [Data Flow: Request → Response](#9-data-flow) -10. [Windows Specifics](#10-windows-specifics) -11. [Multi-Window Registry (v2.3+)](#11-multi-window-registry-v23) -12. [Testing Strategy](#12-testing-strategy) - ---- - -## 1. Core Principles - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Four Layers of Architecture │ -│ │ -│ Layer 1: main.py (Entry points, minimal) │ -│ Layer 2: mcp/server.py (DI routing, tool registration) │ -│ Layer 3: mcp/tools/*.py (32 core + 13 inline + 4 dev)│ -│ Layer 4: core/*.py (Pure business logic) │ -└──────────────────────────────────────────────────────────────────┘ -``` - -**Key rules:** -- **Core layer has NO MCP imports.** It's pure Python with business logic. -- **Tool layer NEVER creates dependencies.** Everything comes from DI. -- **server.py ONLY registers** — no logic, no formatting, no try/except. -- **Dependencies flow downward:** Main ← Server ← Tools ← Core. - ---- - -## 2. Layer Architecture - -### 2.0 Ten-Layer Runtime Architecture (v2.4) - -``` - Layer 0: Filesystem — what files exist on disk? - Layer 1: SystemArtifacts — is this a system path? - Layer 2: Bridge (LSP→MCP) — [deprecated: LSP server removed 2026-07-20] - Layer 3: Registry (IndexerRegistry) — which Indexer owns this project? - Layer 4: StateMachine (ProjectState) — what state is the project in? - Layer 5: RuntimeCoordinator — can we execute this request? - Layer 6: ProjectContext — what does the project look like now? - Layer 7: Passport — which process is running? - Layer 8: Intel Layer — what to do with this information? - Layer 9: MCP Tools / AI Agent — answer to the user -``` - -**Data flow:** -``` -Filesystem → SystemArtifacts → Bridge → Registry → StateMachine - ↓ -MCP Tools ← Intel Layer ← ProjectContext ← RuntimeCoordinator -``` - -**Key rule:** Tool does NOT access Registry, Bridge or Passport directly. -Everything goes through `RuntimeCoordinator.can_execute()` + `ProjectContext.capture()`. - -### 2.1 Entry Points - -| File | Protocol | Purpose | -|------|----------|---------| -| `src/main.py` | MCP STDIO | AI assistant in Zed Chat | -| `src/lsp_main.py` *(deleted)* | LSP STDIO | Replaced by LSP client `src/core/lsp_client.py` | - -Both use the same `create_service_collection()` factory. - -### 2.2 MCP Server - -| `src/mcp/server.py` | **~68 lines** (was 3,100 before refactoring). - -Responsibilities: -1. Resolve project root (`resolve_project_root()`) -2. Create DI container (`create_service_collection()`) -3. Register 32 core + 16 intel + 13 inline + 4 dev = 65 total -4. Register system prompt (mscodebase-rules) - -**No business logic lives here.** Every tool is an import from `mcp/tools/`. - -### 2.3 Tool Layer - -`src/mcp/tools/*.py` — **32 core (25 + codebase hub + 6 LSP) + 13 inline + 4 dev.** - -Every tool: -- Inherits from `MCPTool` (ABC) -- Receives dependencies via constructor (Constructor Injection) -- Has exactly one entry point: `async def execute(**kwargs) -> dict` -- Is decorated with `@error_boundary(tool_name, timeout_ms)` - -```python -class SearchCodeTool(MCPTool): - """search_code — semantic code search.""" - - def __init__(self, services: ServiceCollection): - super().__init__(services, tool_name="search_code") - self.searcher = services.resolve(Searcher) - self.symbol_index = services.resolve(SymbolIndex) - - @error_boundary("search_code", timeout_ms=15000) - async def execute( - self, - query: str, - mode: str = "auto", - limit: int = 6, - kwargs: Optional[Dict[str, Any]] = None, - ) -> dict: - self.require_index() # check index readiness - # ... search logic -``` - -### 2.4 Core Layer - -`src/core/*.py` — **54 top-level files** (123 incl. subpackages) of pure business logic. - -Key modules: - -| Module | Path | Purpose | -|--------|------|---------| -| `di_container.py` | `src/core/di_container.py` | DI Container (14 services) | -| `error_handler.py` | `src/core/error_handler.py` | ToolError + error_boundary | -| `rate_limiter.py` | `src/core/rate_limiter.py` | DebounceBatch + CircuitBreaker | -| `engine.py` | `src/core/search/engine.py` | Hybrid search (BM25 + Dense + FTS5 + RRF) | -| `graph.py` | `src/core/graph.py` | PropertyGraph — SQLite property graph | -| `graph_adapter.py` | `src/core/search/graph_adapter.py` | SymbolIndexAdapter wrapping PropertyGraph | -| `cypher_engine.py` | `src/core/search/cypher_engine.py` | Cypher→SQL facade (facade over cypher_ast/lexer/parser/sql/executor) | -| `indexer.py` | `src/core/indexing/indexer.py` | LanceDB vector storage + indexing pipeline | -| `symbol_index.py` | `src/core/indexing/symbol_index.py` | Call Graph (BFS, PageRank) | -| `parser.py` | `src/core/indexing/parser.py` | Tree-sitter AST parser (32 extensions: core + shell + context + markdown) | -| `file_guard.py` | `src/core/indexing/file_guard.py` | .gitignore + extension filter | -| `db_manager.py` | `src/core/indexing/db_manager.py` | LanceDB table lifecycle (PID-lock, reindex guard) | -| `fts5_mixin.py` | `src/core/search/fts5_mixin.py` | FTS5 full-text search mixin | -| `scoring.py` | `src/core/search/scoring.py` | RRF + MMR diversity scoring | -| `layer.py` | `src/core/intelligence/layer.py` | Intel Layer (16 intel_* tools) | -| `runtime_coordinator.py` | `src/core/runtime_coordinator.py` | ExecutionVerdict + can_execute() | -| `project_context.py` | `src/core/intelligence/project_context.py` | Project state snapshot | -| `llama_runner.py` | `src/providers/reranker/llama_runner.py` | Lifecycle for llama-server.exe (reranker) | -| `remote_embedder.py` | `src/providers/embedder/remote_embedder.py` | Embedder client: llama.cpp GGUF (primary) + ONNX INT8 / LM Studio / Ollama fallback | -| `doc_sync_engine.py` | `src/core/doc_sync_engine.py` | Auto-sync docs with code (rename hook) | - -### 2.5 Search Engine (v3.3) - -``` -┌─────────────────────────────────────────────────────────┐ -│ Search Pipeline (engine.py) │ -│ │ -│ query_str → embed() → BM25 → FTS5 → 3-way RRF → MMR │ -│ ↕ │ -│ MultiSignalScorer: api_signature, graph_diffusion, │ -│ module_proximity, cochange_boost │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ PropertyGraph (graph.py) │ -│ SQLite (WAL + mmap), nodes/edges, JSON properties │ -│ — 16 node labels (File, Function, Class, Variable...) │ -│ — 29 edge types (CALLS, DEFINES, ASSIGNED_FROM, ...) │ -│ — Cypher query engine (MATCH→SQL) │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Data Flow Layer (v3.2.0) │ -│ │ -│ 1. Unified Walker — one Tree-sitter parse → calls + │ -│ assignments. Parse cache with content hash. │ -│ 2. Conditional Flow — ASSIGNED_FROM edges have │ -│ condition_path (if/for/while/try/except) │ -│ 3. Intra-procedural only — within function bodies │ -│ 4. 16 languages: Python, Rust, TS, TSX, Go, JS, │ -│ Java, C#, Ruby, PHP, Kotlin, Swift, C, C++, │ -│ Scala, Dart │ -└─────────────────────────────────────────────────────────┘ -``` - -### 2.6 Embedder: llama.cpp GGUF (native, preferred), ONNX INT8 as fallback - -The MCP server now uses multilingual-e5-small via **llama.cpp GGUF** (`llama-server.exe`) as its primary embedder; **ONNX Runtime (CPU, in-process)** is the fallback when llama.cpp is unavailable (auto-detected at startup — ONNX preload is canceled once llama.cpp is up): - -- **Model**: `intfloat/multilingual-e5-small` (384-dim), GGUF; fallback INT8 ONNX -- **Runtime**: llama.cpp (`:8080`, llama-server), fallback ONNX in-process -- **Architecture**: llama.cpp as a separate process; ONNX in-process only as fallback -- **Performance**: ~16-50 ch/s (llama.cpp CPU, depends on load) -- **RAM**: ~1.7 GB (2× llama-server: embed + reranker); ONNX fallback ~265 MB -- **Config**: model auto-detected (GGUF from `models/`); legacy `EMBEDDING_DIMENSION`/`EMBEDDING_PROVIDER` not used - -The reranker still runs via llama-server (1 process, not 2). - -Legacy fallback providers (LM Studio, Ollama, remote ONNX) remain available via `remote_embedder.py` for custom setups. - ---- - -## 3. DI Container - -### 3.1 ServiceCollection - -```python -# src/core/di_container.py - -services = ServiceCollection() - -# Registering a singleton: -services.add_singleton(Indexer, indexer_instance) - -# Registering a lazy factory: -services.add_factory(Searcher, lambda s: Searcher(s.resolve(Indexer), ...)) - -# Resolving: -indexer = services.resolve(Indexer) # same instance every time -``` - -### 3.2 Registered Services (14) - -| # | Service | Type | Created By | -|---|---------|------|------------| -| 1 | ProjectRootKey | singleton | `project_root` (sentinel key) | -| 2 | CodeParser | singleton | `CodeParser()` | -| 3 | RemoteEmbedder | singleton | `RemoteEmbedder()` | -| 4 | ProjectRegistry | singleton | `ProjectRegistry()` | -| 5 | MultiProjectSearcher | singleton | `MultiProjectSearcher(embedder, registry)` | -| 6 | PropertyGraph | singleton | `PropertyGraph(/projects//graph.db)` | -| 7 | ProjectIndexerRegistry | singleton | `ProjectIndexerRegistry(max_cached=5)` | -| 8 | NotificationBroker | singleton | `NotificationBroker(project_path=project_root)` | -| 9 | IndexerFactoryKey | factory | `_create_indexer_for_path` | -| 10 | SlidingWindowRateLimiter | singleton | `SlidingWindowRateLimiter()` | -| 11 | CircuitBreaker | singleton | `CircuitBreaker(name="lm_studio")` | -| 12 | Path | singleton | `project_root` (resolved path) | -| 13 | Indexer | singleton | `indexer_instance` | -| 14 | GitUrlSourceFactoryKey | factory | `_create_git_url_source` | - -> Cleanup (Task 2/5): dead registrations removed — DbPathKey, FileGuard, -> SymbolIndex, ResourceMonitor, ResourceMonitorKey (never resolved). - ---- - -## 4. Tool Layer - -### 4.1 Tool Registration - -In `src/mcp/server_tools.py`: - -```python -def register_all_tools(mcp, services): - tool_classes = [ - # Search (3) - SearchCodeTool, GetSymbolInfoTool, ImpactAnalysisTool, - # Hub: codebase (write/index/git/notify — multiplexed by action) - CodebaseTool, - # Spoke: AST Sandbox (3-layer: AST validation + runtime __import__ wrapper + subprocess isolation) - ExecuteScriptTool, - # Analysis (5) - StructuralSearchTool, GetRepoMapTool, GetRepoRankTool, - ScanChangesTool, GenerateChunkSummariesTool, - # Graph (3 — Phase 2: graph_query multiplexes cypher + related + flow) - CrossRepoSearchTool, CrossProjectDepsTool, GraphQueryTool, - # Investigation (3) - GetBugCorrelationTool, GetHotspotsTool, FindSimilarBugsTool, - # Lifecycle (4) - SubmitBackgroundTaskTool, GetTaskStatusTool, VerifyActionTool, GetActionReceiptTool, - ] - # +16 intel_* tools + 13 inline diagnostic + 4 dev - # Total: 65 registered (32 core + 16 intel + 13 inline + 4 dev) -``` - -**Tool visibility filter:** the default visible set is the `MSCODEBASE_MCP_TOOLS` allowlist (see `src/mcp/server_tools.py`). Set `MSCODEBASE_MCP_TOOLS=""` to show all 65 (+1 `execute_script` при `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`). - -### 4.2 All Tools by Group - -| Group | File | Tools | -|-------|------|-------| -| **Search** (3) | `search_tools.py` | search_code, get_symbol_info, impact_analysis | -| **LSP** (6) | `lsp_tools.py` | lsp_find_references, lsp_find_definition, lsp_document_symbols, lsp_get_type_info, lsp_get_diagnostics, lsp_get_code_actions | -| **Hub: codebase** (1) | `codebase_tool.py` | codebase(action=index/git/rename/move/delete/replace/insert/notify) | -| **Spoke: Sandbox** (1) | `codebase_tool.py` | execute_script(code) — AST + runtime import wrapper + subprocess isolation (опционально, env-флаг) | -| **Analysis** (5) | `analysis_tools.py` | structural_search, get_repo_map, get_repo_rank, scan_changes, generate_chunk_summaries | -| **Graph** (4) | `graph_tools.py` | cross_repo_search, cross_project_deps, graph_query, detect_communities | -| **Investigation** (3) | `investigation_tools.py` | get_bug_correlation, get_hotspots, find_similar_bugs | -| **Duplication** (1) | `duplication_tool.py` | find_duplicates | -| **Context** (1) | `context_tool.py` | get_context | -| **Lifecycle** (4) | `lifecycle_tools.py` | submit_background_task, get_task_status, verify_action, get_action_receipt | -| **Docs** (1) | `doc_tools.py` | stale_detector | -| **Dev** (4) | `dev_tools.py` | generate_docs, bump_version, auto_update_docs, install_git_hooks | -| **Intelligence** (16) | `intelligence/tools_reg.py` | intel_get_runtime_status, intel_trigger_reindex, intel_reset_index, intel_get_job_status, intel_code_topology, intel_log_incident, intel_get_project_memory, intel_add_memory_node, intel_auto_collect_adrs, intel_get_hotspots, intel_analyze_incident, intel_predict_root_cause, intel_get_telemetry, intel_retract_memory_node, intel_restore_memory_node, intel_supersede_memory_node | -| **Diagnostic inline** (13) | `server_tools.py` | debug_runtime_passport, intel_get_project_context, intel_explain_project_state, get_runtime_counters, intel_tool_health, intel_execution_timeline, refresh_db_connection, notify_change, read_live_file, get_logs, get_health_report, dual_arm_health_check, ack_impact | - -> **Total:** 65 registered (32 core + 16 intel + 13 inline + 4 dev). Default visible: `MSCODEBASE_MCP_TOOLS` allowlist (see `src/mcp/server_tools.py`). Show all: `MSCODEBASE_MCP_TOOLS=""`. - -## 5. Error Handling - -### 5.1 error_boundary Decorator - -Every tool is wrapped with `@error_boundary`: - -```python -@error_boundary("tool_name", timeout_ms=15000, max_retries=1) -async def execute(self, **kwargs) -> dict: - ... -``` - -It guarantees: -1. **Real timeout** via `asyncio.wait_for(timeout_ms / 1000.0)` -2. **Unified JSON** always: `{"status": "ok"|"error"|"timeout"|"warning", "message": "...", "detail": "...", "latency_ms": 123}` -3. **Controlled errors** (`ToolError`) → return as-is without retry -4. **Unexpected errors** → logged with full traceback, returned as `"status": "error"` -5. **Timeout retry** — configurable via `max_retries` - -### 5.2 ToolError Hierarchy - -```python -ToolError # Base: status, message, detail, recoverable -├── IndexNotReadyError # Index empty (warning, recoverable) -└── RateLimitError # Rate limit exceeded (warning, recoverable) -``` - ---- - -## 6. Rate Limiting & Resilience - -### 6.1 SlidingWindowRateLimiter - -```python -limiter = SlidingWindowRateLimiter() # asyncio.Lock for thread safety - -ok = await limiter.acquire("notify_change", max_per_sec=10.0) -if not ok: - raise RateLimitError(detail="Too many notify_change calls") -``` - -### 6.2 DebounceBatch - -Replaces immediate `searcher.reindex()` on every file change: - -```python -batch = DebounceBatch(callback=searcher.reindex, config=DebounceConfig( - debounce_ms=500, # 500ms after last event - max_batch_size=100, # or flush immediately at 100 files - max_wait_ms=5000, # prevent infinite debounce -)) -await batch.add("file.py") # BM25 rebuilds after 500ms (or at 100 files) -``` - -### 6.3 CircuitBreaker - -```python -cb = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0, name="lm_studio") - -result = await cb.call( - lambda: embedder.embed_batch(texts), - fallback={"status": "fallback", "message": "LM Studio unavailable"} -) -# States: CLOSED → OPEN (5 failures) → HALF_OPEN (30s later) → CLOSED (success) -``` - ---- - -## 7. Data Flow - -``` -Zed AI Agent - │ - ▼ -MCP Tool Call (e.g., search_code("find indexer")) - │ - ▼ -error_boundary decorator - ├── timeout check (asyncio.wait_for) - ├── rate limit check (SlidingWindowRateLimiter) - └── tool execution - │ - ▼ - MCPTool.execute(**kwargs) - │ - ├── self.require_index() → IndexNotReadyError if empty - ├── services.resolve(Searcher) - ├── engine.hybrid_search(query) - │ │ - │ ▼ - │ core/search/engine.py - │ ├── BM25 search (in-memory TF-IDF) - │ ├── Vector search (LanceDB + llama.cpp GGUF, ONNX fallback) - │ ├── FTS5 search (SQLite FTS5, trigram+porter) - │ └── 3-way RRF fusion + MMR diversity - │ - └── return {"status": "ok", "results": [...]} - │ - ▼ - error_boundary → {"status": "ok", ...latency_ms} - │ - ▼ - Zed Chat (formatted JSON response) -``` - ---- - -## 8. Metadata Enrichment (v2.4.4+) - -### 8.1 Chunk Metadata - -Each chunk in LanceDB stores 6 metadata fields for deterministic -filtering and multi-granularity retrieval: - -| Field | Type | Example | Purpose | -|-------|------|---------|---------| -| `layer` | string | `"core"` | Architecture layer: core/mcp/utils/tests/... | -| `module_name` | string | `"core.parser"` | Logical module name from file path | -| `hierarchy_level` | string | `"method"` | Level: function/method/class/impl/lines | -| `is_public` | bool | `true` | Public/private (`_`-prefixed) | -| `symbol_type` | string | `"method_definition"` | AST node type | -| `parent_id` | string | md5 hash | Deterministic parent hash | - -Layer detection — automatic, by file path: - -| Path | layer | -|------|-------| -| `src/core/*` | `core` | -| `src/mcp/tools/*` | `mcp_tools` | -| `src/mcp/*` | `mcp` | -| `src/utils/*` | `utils` | -| `tests/*` | `tests` | -| `docs/*` | `docs` | -| `.agents/*` | `agents` | -| `scripts/*` | `scripts` | -| `.github/*` | `ci` | -| other | `root` | - -### 8.2 Flat Tree Hierarchy (SproutRAG-style) - -`parent_id` — deterministic md5 hash: - -- **For method:** `md5(file_path + "::" + class_name)` — parent = class -- **For function:** `md5(file_path)` — parent = module -- **For giant function part:** `md5(file_path + "::" + symbol_name)` — parent = function - -Enables multi-granularity retrieval without graph DB: -- Find all methods of a class → `get_chunks_by_parent_id("md5_hash")` -- Navigate up to module → aggregation by parent_id - -### 8.3 Layer Filtering in search_code - - ```python - # Only core layer - search_code(query="DI container", filter_layer="core") - - # Only tests - search_code(query="test_parser", filter_layer="tests") - - # No filter (all layers, as before) - search_code(query="parser") - ``` - - Layer filtering works at the LanceDB level via `.where(prefilter=True)` — vector search only searches chunks of the specified layer. BM25 post-filters by layer from metadata. - - --- - - ## 9. Windows Specifics - -### 8.1 Path Resolution - -`PROJECT_PATH` may contain `$ZED_WORKTREE_ROOT` literal string (env var not resolved by Zed on Windows). -Solution: `resolve_project_root()` checks 7 fallback strategies: - -1. Provided argument -2. LSP→MCP bridge (temp file from LSP, which knows `root_uri`) -3. `PROJECT_PATH` env var (resolved if not `$ZED`) -4. `ext_root` if it's a git repo -5. `ZED_WORKTREE_ROOT` env var -6. CWD (from Zed `settings.json`) -7. `ext_root` as final fallback - -### 8.2 Git Subprocess Safety - -```python -env["GIT_TERMINAL_PROMPT"] = "0" # No interactive prompts -env["GIT_ASKPASS"] = "echo" # No credential helper -env["GIT_PAGER"] = "cat" # No pager -creationflags = subprocess.CREATE_NO_WINDOW # No console window -``` - -### 8.3 Long Path Support - -SafePathManager uses `to_win_long_path()` (prepending `\\?\`) for paths > 260 chars. - ---- - -## 9. Multi-Window Registry (v2.3+) - -v2.3+ supports **multiple open projects in Zed simultaneously**. -Previously DI held a singleton `Indexer` — when switching windows, state would break -(one `file_guard`, one `db_path`, shared `SymbolIndex`). - -### 9.1 `ProjectIndexerRegistry` - -`src/core/indexing/project_indexer_registry.py` — thread-safe registry of `Indexer` objects: - -```python -registry = ProjectIndexerRegistry( - max_cached=5, # LRU limit (5 projects = 1-2.5GB RAM) - resource_monitor=get_global_resource_monitor(), # adaptive throttling -) - -# Per-project lazy creation via factory: -def _create_indexer(p: Path) -> Indexer: - return Indexer( - db_path=_generate_unique_db_path(p), - file_guard=FileGuard(p), - symbol_index=SymbolIndex(), # isolated - project_path=p, ... - ) - -services.add_singleton(IndexerFactoryKey, _create_indexer) -indexer = registry.get_indexer(project_path, factory=_create_indexer) -``` - -**Guarantees:** -- **Isolation:** each window gets its own `FileGuard`/`SymbolIndex`/`db_path`. -- **LRU:** when the 6th project opens, the oldest `Indexer` is evicted. -- **Pressure-evict:** when RAM > 1GB or CPU > 85% — forced evict - **before** creating a new `Indexer` (prevents OOM). -- **Cleanup:** `_safe_close()` resets LanceDB connection + `gc.collect()` - (for Windows mmap handles). - -### 9.2 `ResourceMonitor` - -`src/core/indexing/resource_monitor.py` — stdlib-only monitoring (no `psutil`): - -| Platform | Method | -|-----------|-------| -| POSIX | `resource.getrusage(RUSAGE_SELF).ru_maxrss` | -| Windows | `psapi.GetProcessMemoryInfo` via `ctypes` | -| CPU | `resource.getrusage` utime+stime delta / wall-clock | - -**Thresholds:** -- Soft: 768MB / 75% CPU → throttle indexing (0.1s delay between files) -- Hard: 1024MB / 85% CPU → pressure-evict + 0.5-2s delay - -```python -monitor = get_global_resource_monitor() -snap = monitor.sample() # ResourceSnapshot (rss_mb, cpu_percent, threads) - -if monitor.is_under_pressure(): - delay = monitor.suggest_throttle_delay_sec() - time.sleep(delay) # in Indexer.index_project between files -``` - -### 9.3 Per-workspace DI (multi-window) - -Standalone LSP (`src/lsp_main.py`) is **deleted** (WONTFIX, см. -`docs/en/investigations/LSP_WONTFIX.md`). Per-workspace DI lives in -`ProjectIndexerRegistry` (v2.3+): - -```python -registry: ProjectIndexerRegistry = services.resolve(ProjectIndexerRegistry) -indexer = registry.get_indexer(project_path, factory=factory) -# → creates isolated per-project Indexer (LRU max_cached=5 + pressure eviction) -``` - -Each workspace/project window gets its own Indexer with its own LanceDB DB -and PropertyGraph (paths hashed outside the project — Задача 4/5). - -### 9.4 MCP `resolve_indexer_for_request` - -`src/mcp/tools/base.py` — single entry point for per-project Indexer: - -```python -def resolve_indexer_for_request(services, explicit_project_root=None): - target = explicit_project_root or resolve_project_root() or DI_default - registry = services.resolve(ProjectIndexerRegistry) - factory = services.resolve(IndexerFactoryKey) - return registry.get_indexer(target, factory=factory) - -class MCPTool: - def resolve_indexer(self, project_root=None): - return resolve_indexer_for_request(self._services, project_root) -``` - -**All MCP tools** must use `self.resolve_indexer(...)` -instead of `self._services.resolve(Indexer)` — the latter no longer works -(Indexer is not a singleton). - -### 9.5 HealthReport `_check_resources` - -`src/core/code_health.py` — added method: - -```python -def _check_resources(self): - summary = get_global_resource_monitor().get_summary() - self.metrics["process_rss_mb"] = summary["rss_mb"] - self.metrics["process_cpu_percent"] = summary["cpu_percent"] - self.metrics["registry_cached_projects"] = ... - self.metrics["registry_evictions"] = ... - if summary["under_hard_pressure"]: - self.issues.append({...}) -``` - ---- - -## 10. Testing Strategy - -``` -tests/ -├── test_error_handler.py # 18 tests — ToolError, error_boundary -├── test_rate_limiter.py # 21 tests — SlidingWindow, DebounceBatch, CircuitBreaker -├── test_di_container.py # 13 tests — ServiceCollection, 11 services -├── test_resource_monitor.py # 11 tests — ResourceMonitor + ProjectIndexerRegistry (v2.3+) -├── test_parser.py # 4 tests — Tree-sitter parsing -├── test_execution_contract.py# 10 tests — verify_action -├── test_task_queue.py # 6 tests — background task queue -├── test_branch_aware_index.py# 8 tests — get_branch_info -├── test_symbol_index_call_graph.py # 8 tests — call graph -├── ... (20 more test files) -``` - -**Total: 853 tests.** - -Run: -```bash -pytest tests/ -m "not integration and not benchmark" -``` - ---- - -## Quick Reference - -| Command | Description | -|---------|-------------| -| `python -m src.main` | Run MCP server (STDIO) | -| `pytest tests/` | Run all tests | -| `pytest tests/test_di_container.py -v` | Run DI container tests only | -| `python -c "from src.mcp.server import create_mcp_server; mcp = create_mcp_server()"` | Verify server loads | - ---- - -## 11. Architectural Invariants - -These rules must NOT be violated by any new PR. - -``` -1. Tool does not access Registry directly. -2. Tool does not read Bridge directly. -3. Tool works only through RuntimeCoordinator. -4. RuntimeCoordinator does not know about Search / Indexer / Memory. -5. ProjectContext is an immutable snapshot (does not start operations). -6. All system files are defined only through SystemArtifacts. -7. The indexer never indexes system artifacts. -8. Any project path goes through the single resolver (resolve_project_root). -9. All Intel tools use ProjectContext (not low-level APIs). -10. Any new runtime component must have a single responsibility. -11. Core layer has no MCP imports. -12. Tools do not create dependencies — everything through DI. -13. server.py registers — does not contain business logic. -``` - -**Code review check:** every PR must answer the question -"Which existing layer does this extend?". If the answer is "none, I created -a new Manager/Services/Provider" — this is a reason to stop and reconsider. +## 1. Design goals + +The server exists to give an AI assistant grounded, local answers about a +codebase without making a remote service or a large model mandatory. + +1. **Local-first.** Core search, graph queries and project memory work without + network calls. +2. **Deterministic before probabilistic.** Exact symbols, paths and BM25 are + tried before optional semantic layers. +3. **Fail open, not silent.** Missing embedder, empty graph or stale index must + produce a diagnosis, not a fabricated answer. +4. **One contract for humans and agents.** The same tool schema is used by the + Zed agent, scripts and tests. +5. **Observable.** Runtime passport, index status, health report and logs are + first-class tools, not debug afterthoughts. + +## 2. Runtime at a glance + +```text +Zed + ├── built-in LSP (autocomplete, diagnostics) + └── MCP client + │ + ▼ + src/main.py (stdio) or src/remote_main.py (HTTP) + │ + ▼ + server_factory.create_mcp_server() + ├── create_service_collection() DI container + ├── register_all_tools() 64 registered tools + └── notification / extension hooks + │ + ├── Search core/search + ├── Index core/indexing + ├── Graph core/graph.py + core/search/graph_adapter.py + ├── Memory core/intelligence + ├── Writes core/modification_guard.py + mcp/tools/write_tools.py + └── Optional providers/embedder, providers/reranker, LSP client + │ + ▼ + Storage + ├── /.mscodebase/chunks.db SQLite + FTS5 + ├── /projects//graph.db PropertyGraph + ├── /projects//intelligence/ memory, incidents + └── /logs/ server logs +``` + +`` is `%LOCALAPPDATA%/mscodebase` on Windows, or the platform +equivalent. Project artifacts live outside the repository; `chunks.db` is the +only store that remains inside the project directory. + +## 3. Layers + +| Layer | Responsibility | Main modules | +|---|---|---| +| Entry | Process start, project-root resolution, stdio/HTTP transport | `src/main.py`, `src/remote_main.py` | +| Protocol | MCP server, lifecycle, notification/extension hooks | `src/mcp/server.py`, `server_factory.py` | +| Tool | Thin adapters: validation, formatting, error boundary | `src/mcp/tools/*.py`, `server_tools.py` | +| Core | Search, indexing, graph, memory, write guards | `src/core/*` | +| Providers | Optional embedders, rerankers, LSP client | `src/providers/*`, `src/core/lsp_client.py` | +| Storage | SQLite/FTS5, PropertyGraph, JSON memory, logs | `chunk_store.py`, `graph.py`, `intelligence/` | + +The tool layer never implements ranking or parsing itself. It resolves a +project-scoped service and delegates to core. + +## 4. Entry points and lifecycle + +- `src/main.py` starts the stdio MCP server used by the Zed extension. It + resolves the extension/project root, configures logging and starts the + server loop. +- `src/remote_main.py` exposes the same server over an authenticated HTTP + transport for remote use. It is optional and off unless configured. +- `src/mcp/server_factory.py` creates the server, builds the DI container, + registers tools, wires notifications and registers shutdown handlers. +- `src/mcp/server_tools.py` owns registration and the tool allowlist. + +A second project in another Zed window is isolated by +`ProjectIndexerRegistry`: each project gets its own indexer/searcher/graph +handles, with an LRU bound and resource monitoring. + +## 5. Dependency injection + +`src/core/di_container.py` provides `ServiceCollection` and +`create_service_collection(project_root)`. Services are singletons scoped to the +server process: + +- project root and parser; +- indexer/searcher and optional embedder; +- PropertyGraph and symbol index; +- `ProjectIndexerRegistry` / `MultiProjectSearcher`; +- rate limiter, circuit breaker and notification broker. + +Tool classes receive the collection through constructor injection. They do not +construct indexers or open databases directly. Shutdown closes registered +resources and releases background tasks. + +## 6. Tool layer + +The server registers **64 tools**: + +- 31 core tool classes (search, LSP, graph, analysis, write, lifecycle); +- 16 `intel_*` tools; +- 13 inline diagnostic tools; +- 4 dev tools. + +`execute_script` is a 65th, optional tool and is registered only when +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. The default allowlist exposes **49** +tools; `MSCODEBASE_MCP_TOOLS` controls visibility (an empty value shows all +registered tools). + +`codebase(action=...)` is a hub over index, git and write operations. Search +has a dedicated `search_code` tool and a `plan_search` helper that returns a +deterministic plan (primary query, exact tokens, symbols, concepts, intent). + +All tools are wrapped by `error_boundary`, which applies a timeout, records +telemetry and returns a structured error instead of hanging the MCP loop. + +## 7. Indexing pipeline + +```text +discover files + → FileGuard (extensions, size, binaries, system dirs, .gitignore) + → parser (Tree-sitter; language-specific extractors) + → chunks + symbols + calls/imports/assignments/decorators/overrides + → SQLiteChunkStore (text + FTS5) and PropertyGraph +``` + +Key rules: + +- `experiments/**` is **excluded from the index**. It contains frozen panels and + run artifacts; indexing them caused BM25 to return the panel itself instead of + code. The files remain tracked in git. +- Incremental indexing updates a file's chunks and graph rows in one + transaction. +- Full vector reindex is intentionally blocked when `MSCODEBASE_DENSE_OFF=true`. + Use `scripts/reindex_text_only.py` for text chunks and + `scripts/rebuild_graph.py` for a graph-only rebuild. +- A `build_head` marker is written only after a full successful index, so + freshness can be checked against git HEAD. +- `FileGuard` and `SafePathManager` reject paths outside the project and system + directories before parsing. + +## 8. Storage + +| Store | Path | Purpose | +|---|---|---| +| Chunk store | `/.mscodebase/chunks.db` | Chunk text, metadata, FTS5 index | +| Graph | `/projects//graph.db` | Symbols, files and typed edges | +| Memory | `/projects//intelligence/` | Project memory, incidents, jobs | +| Logs | `/logs/` | Server and tool diagnostics | + +LanceDB is a legacy/optional vector store. It is not required by the default +text-first path and is not the primary source of candidates when +`MSCODEBASE_SQLITE_CHUNKS=true`. + +## 9. Search integration + +Search is the largest core subsystem. The default path is: + +1. SQLite FTS5/BM25 candidates; +2. optional symbol/graph stage for identifier-shaped queries; +3. optional on-demand rerank; +4. exact-name boost and symbol deduplication; +5. docs routing at the tool level when the BM25 margin is strong. + +The full RRF/MMR/anchor pipeline in `core/search/engine.py` belongs to the +optional dense/LanceDB path or to older code paths. It is not silently applied +to every Phase 3 query. See [SEARCH_PIPELINE.md](SEARCH_PIPELINE.md) for the +exact split. + +## 10. Intelligence and memory + +`core/intelligence/` implements project memory and incident handling: + +- ADRs and known issues are stored as records with status and evidence; +- incidents can be logged, searched and predicted from runtime data; +- `verify-on-read` can mark memory facts as verified, refuted or superseded; +- retraction/supersede preserve history instead of deleting it. + +Memory is local and project-scoped. It is not a general-purpose chat log. + +## 11. Writes and safety + +Write tools (`rename`, `move`, `safe_delete`, `replace`, `insert_before/after`) +operate on symbols, not raw text replacements. The pipeline is: + +1. resolve the symbol and its references; +2. compute an impact summary; +3. require `ack_impact` when the file is load-bearing; +4. preview the change; +5. apply only with `apply=true`, then refresh chunks/graph. + +Cross-file rename may use the basedpyright LSP client; on timeout it falls back +to the SymbolIndex. `execute_script` is disabled by default and, when enabled, +uses AST validation, an import wrapper and a subprocess sandbox. + +## 12. Concurrency and multi-window behavior + +- One indexer/searcher per project, resolved through the registry. +- SQLite writes are serialized by the chunk store lock; reads are safe across + threads. +- Background indexing uses a watchdog heartbeat and job state, so the agent can + poll instead of blocking the editor. +- External processes use `Popen` + `communicate(timeout=...)`, not + `capture_output` in daemon threads, to avoid Windows pipe deadlocks. +- A reindex fast-fail returns a clear "index is rebuilding" error instead of + hanging. + +## 13. Failure modes and operational checks + +| Symptom | First check | Fix | +|---|---|---| +| Graph tools return nothing | `graph.db` nodes/edges | `python scripts/rebuild_graph.py` | +| Search returns experiment docs | index contains `experiments/**` | reindex; FileGuard now excludes them | +| Docs always miss | gold is `docs/en`, result is `docs/ru` | normalize by basename | +| Index is stale | `codebase(action="index", path="status")` | `scripts/reindex_text_only.py` | +| Embedder missing | `debug_runtime_passport()` | text path still works; configure provider if needed | +| Tools missing from client | `MSCODEBASE_MCP_TOOLS` | add names or set empty for all | + +## 14. Testing and verification + +- `pytest tests/` — unit/integration suite; markers separate integration and + benchmark tests. +- `python scripts/smoke_e2e.py --project .` — real embed/rerank/search, no + mocks, when providers are available. +- `python scripts/rebuild_graph.py` and `python scripts/reindex_text_only.py` + are the supported repair paths for graph/index drift. +- Search experiments live under `experiments/` with frozen inputs and raw runs; + they are the source for the quality numbers in the README. + +## 15. Related documents + +- [SEARCH_PIPELINE.md](SEARCH_PIPELINE.md) — retrieval, ranking, modes and limits. +- [ARCHITECTURE_DEEP.md](ARCHITECTURE_DEEP.md) — historical deep dive and + optional pipeline details. +- [GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md) — behavior when providers + are unavailable. +- [INSTALL.md](INSTALL.md) — installation and repair commands. +- [README.md](../../README.md) — current public contract and measured quality. diff --git a/docs/en/ARCHITECTURE_DEEP.md b/docs/en/ARCHITECTURE_DEEP.md index a911c719..e2d45844 100644 --- a/docs/en/ARCHITECTURE_DEEP.md +++ b/docs/en/ARCHITECTURE_DEEP.md @@ -4,9 +4,14 @@ > **Version:** v3.5.0 | **Last updated:** 2026-08-12 +> **Phase 3 status (2026-10-25):** default is text-first (SQLite FTS5/BM25 + +> PropertyGraph); vector/LanceDB and phi-4 `ask` mode are optional. This is a +> historical deep-dive; current contract and limits are in +> [README.md](../../README.md). + ```mermaid flowchart TD - User[User / AI Agent] --> MCP[MCP Server\n61 tools] + User[User / AI Agent] --> MCP[MCP Server\n64 tools] MCP --> DI[DI Container\n15+ services] DI --> Search[Search Pipeline] DI --> Index[Indexing Pipeline] @@ -207,7 +212,7 @@ sequenceDiagram end DI-->>MCP: Container ready - MCP->>MCP: Register 61 tools + MCP->>MCP: Register 64 tools MCP-->>Zed: Server ready (PID announced) Note over Zed,DB: Total startup: ~2-5s (async embedder init) diff --git a/docs/en/CONTRIBUTING.md b/docs/en/CONTRIBUTING.md index c522e673..c7402fbf 100644 --- a/docs/en/CONTRIBUTING.md +++ b/docs/en/CONTRIBUTING.md @@ -19,7 +19,7 @@ pip install -r requirements.txt pip install -e "." ``` -Requirements: Python 3.10+, Windows (primary) or Linux (experimental). +Requirements: Python 3.14+, Windows (primary) or Linux (experimental). --- diff --git a/docs/en/INSTALL.md b/docs/en/INSTALL.md index f72a6526..19d185a2 100644 --- a/docs/en/INSTALL.md +++ b/docs/en/INSTALL.md @@ -6,9 +6,13 @@ [🇬🇧 English](INSTALL.md) • [🇷🇺 Русский](../ru/INSTALL.md) • [🇨🇳 中文](../zh/INSTALL.md) -> **MSCodebase Intelligence** — MCP server for semantic code search in Zed IDE. +> **MSCodebase Intelligence** — MCP server for local code intelligence in Zed IDE. > Runs as a Zed extension. Development in ``. +> **Phase 3 note (2026-10-25):** the default install is text-first +> (SQLite FTS5/BM25 + PropertyGraph). Model downloads and the vector path are +> optional; LanceDB is legacy/optional, not the default retrieval store. + --- ## 🔧 System Requirements diff --git a/docs/en/SEARCH_PIPELINE.md b/docs/en/SEARCH_PIPELINE.md index a6b253c5..3631311d 100644 --- a/docs/en/SEARCH_PIPELINE.md +++ b/docs/en/SEARCH_PIPELINE.md @@ -1,295 +1,223 @@ -# Search Pipeline — Complete Technical Reference - -> **Part of MSCodeBase Intelligence** | v3.5.0 - -## Overview - -The search pipeline is the core of MSCodeBase. It combines **4 retrieval stages + MultiSignalScorer** to find the most relevant code context. - -```mermaid -flowchart TD - Q[User Query] --> PRE[Preprocessing] - PRE --> INT{Intent Detection} - INT -->|code intent| CW[Code-optimized weights] - INT -->|docs intent| DW[Docs-optimized weights] - INT -->|auto| BW[Balanced weights] - - CW --> PAR[Parallel Search] - DW --> PAR - BW --> PAR - - subgraph PAR[Parallel Search — 2 channels] - BM25[BM25 Sparse\nkeyword match] - DENSE[LanceDB Dense\nsemantic search] - end - - BM25 --> RRF[RRF Fusion\nk=60] - DENSE --> RRF - - RRF --> MULTI[MultiSignalScorer v3.0] - MULTI --> BUCKET[Multi-Bucket RAG] - - subgraph MULTI[MultiSignalScorer — 4 signals] - API[api_signature\nJaccard symbol match] - DIFF[graph_diffusion\nPageRank centrality] - MOD[module_proximity\nhierarchy closeness] - CO2[cochange_boost\ngit coupling] - end - - BUCKET --> GRAPH[Graph expand\ncallees from AST] - GRAPH --> RERANK[Cross-encoder\nbge-reranker-v2-m3] - - RERANK --> CUT[Cut to limit] - CUT --> FMT[Format response] - FMT --> RES[Final Results] +# Search Pipeline — Current Contract + +> **Current as of 2026-10-25.** The default runtime is Phase 3 text-first: +> SQLite FTS5/BM25 plus PropertyGraph. Dense, LanceDB and reranker stages are +> optional and are disabled unless configured. This document describes what the +> default path actually does; it does not describe the old pipeline as if it +> were still the default. + +--- + +## 1. Contract + +Input: + +- a natural-language or identifier query; +- `mode`: `fast`, `quality`, `deep`, `context`, `ask`, `auto`; +- optional `layer`, `intent`, `limit`, `explain`. + +Output: + +- ranked chunks/files with metadata (`file`, `chunk_index`, `symbol_name`, + `symbol_kind`, `layer`, line range, source); +- a stale-index banner when freshness checks fail; +- optional security metadata for untrusted indexed content. + +The agent may pass `intent="docs"` or `intent="code"`. When `intent` is empty, +the tool compares the best docs-only BM25 score with the best code score and +routes to the docs channel when the margin exceeds `MSCODEBASE_DOCS_MARGIN` +(default `4.0`). There is no LLM in this decision. + +## 2. Default Phase 3 path + +```text +query + │ + ├─ normalize / optional RU synonyms + │ + ├─ SQLiteChunkStore.search(query, limit=20..50) + │ └─ FTS5/BM25 over chunk text, symbol_name, docstring, file_path + │ + ├─ if query looks like an identifier/path: + │ └─ PropertyGraph stage adds exact symbol/file candidates + │ + ├─ optional on-demand rerank (only if configured) + │ + ├─ exact-name boost + ├─ symbol deduplication + │ + └─ return top-N ``` -## Stage Details - -### 1. Query Expansion - -```python -_QUERY_SYNONYMS = { - "auth": ["authentication", "login", "authorize"], - "error": ["exception", "failure", "bug"], - "create": ["add", "insert", "new"], - # ... 36 more groups (всего 39 — src/core/search/utils.py) -} - -def expand_query(query: str, max_expansions: int = 3) -> list[str]: - """Generate synonym variants. Each variant is searched independently.""" - variants = [query] - words = query.lower().split() - for word in words: - synonyms = _EXPANSION_SYNONYMS.get(word, []) - for syn in synonyms[:max_expansions - 1]: - variant = query.replace(word, syn, 1) - if variant not in variants: - variants.append(variant) - return variants -``` +The SQLite path is deliberately shorter than the historical LanceDB pipeline. +In particular, it does **not** run RRF over BM25+dense+graph, MMR, co-change +boosts or P3 module-head anchors by default. Those stages live in the optional +dense/LanceDB path or in older code paths. -### 2. BM25 Search (Sparse) - -- **Purpose:** Exact keyword matching — finds code containing specific terms -- **Index:** Incremental, built from LanceDB chunks, stored as `Dict[doc_id, Dict[term, tf-idf]]` -- **Update:** DebounceBatch (500ms) on file changes, full rebuild on reindex -- **Performance:** O(log N) per query - -```mermaid -flowchart LR - subgraph BM25[BM25 Index Building] - A[LanceDB chunks] --> B[Tokenize + TF] - B --> C[Compute IDF] - C --> D[Score matrix\nterm → doc → tf-idf] - end - subgraph BM25Q[BM25 Query] - Q[Query] --> QT[Tokenize] - QT --> SC[Sum tf-idf per doc] - SC --> TOP[Top-k results] - end -``` +## 3. Candidate sources -### 3. Dense Search (Vector, LanceDB) +### 3.1 SQLite FTS5/BM25 -- **Purpose:** Semantic similarity — finds conceptually related code -- **Model:** `multilingual-e5-small-int8` (384-dim) -- **Provider:** ONNX INT8 in-process (primary) / LM Studio (fallback) -- **Index:** LanceDB v2 with IVF-PQ quantization +`SQLiteChunkStore.search` builds an FTS5 `MATCH` expression from query terms +(OR-joined, quoted), ranks with SQLite `bm25()`, and returns: -```python -async def dense_search(query_vector: list, limit: int) -> list: - table = await ensure_async_table() - builder = await table.search(query_vector, vector_column_name="vector") - df = await builder.limit(limit).to_pandas() - return [{"text": row["text"], "metadata": {...}} for _, row in df.iterrows()] -``` +- chunk text and full text; +- file path, chunk index, symbol name/kind, docstring, layer; +- start/end line; +- `final_score` derived from the BM25 rank. -### 4. RRF Fusion (Reciprocal Rank Fusion) - -> ⚠️ **Important:** Ranks are computed **separately** for each channel, starting from 1. -> A combined `enumerate(bm25 + dense)` would produce incorrect scores. - -```python -def rrf_fusion(bm25: list, dense: list, k: int = 60) -> list: - """Merge BM25 + dense results using RRF formula. - - Ranks are computed separately for each channel (starting from 1), - as required by the mathematically correct RRF formula. - """ - scores = {} - results_map = {} - - # BM25 ranks (separate enumerate with start=1) - for rank, doc in enumerate(bm25, 1): - key = f"{doc['metadata']['file']}:{doc['metadata']['chunk_index']}" - scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank) - if key not in results_map: - results_map[key] = { - **doc, - "bm25_score": 1.0 / (k + rank), - "dense_score": 0.0, - } - else: - results_map[key]["bm25_score"] = 1.0 / (k + rank) - - # Dense ranks (separate enumerate with start=1) - for rank, doc in enumerate(dense, 1): - key = f"{doc['metadata']['file']}:{doc['metadata']['chunk_index']}" - scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank) - if key not in results_map: - results_map[key] = { - **doc, - "bm25_score": 0.0, - "dense_score": 1.0 / (k + rank), - } - else: - results_map[key]["dense_score"] = 1.0 / (k + rank) - - for key in results_map: - results_map[key]["final_score"] = ( - results_map[key]["bm25_score"] + results_map[key]["dense_score"] - ) - - return sorted(results_map.values(), key=lambda x: x["final_score"], reverse=True) -``` +The table is content-linked (`content='chunks'`) with insert/update/delete +triggers, so deleting a file removes its FTS rows automatically. -### 5. Multi-Bucket RAG - -```mermaid -flowchart LR - subgraph Buckets - CODE[Code Bucket\nweight=1.0] - DOCS[Docs Bucket\nweight=1.0] - end - subgraph Intent - CODE_HINT[intent_hint=code\ncode*1.2, docs*0.8] - DOCS_HINT[intent_hint=docs\ncode*0.8, docs*1.2] - AUTO[intent_hint=auto\nneutral] - end - subgraph Extension Detection - EXT[os.path.splitext\n→ .py, .rs = code\n→ .md, .json = docs] - end - RESULTS[Scored results] --> EXT --> |code ext| CODE - EXT --> |docs ext| DOCS - CODE --> |multiply by| CODE_HINT - CODE_HINT --> FINAL[final_score] - DOCS --> |multiply by| DOCS_HINT - DOCS_HINT --> FINAL -``` +### 3.2 Symbol and path signals -### 6. Co-change Boost - -Uses git history to boost files that historically change together: - -```python -def apply_co_change_boost(chunks: list) -> list: - """Boost files coupled with top-3 results via git history.""" - top_files = {c["metadata"]["file"] for c in chunks[:3]} - matrix = commit_memory.compute_co_change_matrix() - - for chunk in chunks: - file = chunk["metadata"]["file"] - partners = matrix.get(file, {}) - if partners and any(tf in partners for tf in top_files): - best = max(partners.get(tf, 0) for tf in top_files) - chunk["final_score"] *= (1.0 + best * 0.3) - return chunks -``` +Identifier-shaped queries (`snake_case`, `CamelCase`, paths, `::`) get a +deterministic graph stage. The graph returns exact definition/reference rows +that FTS may rank below generic text matches. Duplicates are removed by +`file + chunk_index`. -### 7. Cross-encoder Reranker +### 3.3 Documentation channel -**Model:** `bge-reranker-v2-m3` (via LM Studio / Ollama) +`Searcher.search_docs()` runs the same FTS5 store and keeps only `docs/**` +files. It collapses language variants (`docs/en/X`, `docs/ru/X`, `docs/zh/X`) +to one key, preferring the query language. This is a separate channel, not a +change to code ranking. -> **Note:** The reranker visualization assumes LM Studio (GPU). With ONNX Runtime fallback, the reranker uses the same `bge-reranker-v2-m3` model via ONNX Runtime (CPU), with reduced throughput. +### 3.4 Optional dense/vector path -- Evaluates each (query, chunk) pair independently — **more accurate than vector cosine** -- Only reranks top-30 candidates (controlled by `MAX_RERANKER_INPUT`) -- Falls back gracefully if LM Studio is unavailable +When `MSCODEBASE_DENSE_OFF=false` and a provider is available, vector search +runs over LanceDB or the configured backend. This path can add candidates but +is not required for the default contract. -```python -async def rerank(query: str, candidates: list, top_n: int = 5) -> list: - """Single-stage reranker: query + chunk → relevance score.""" - if not candidates: - return candidates - scores = await multi_reranker.rerank(query, candidates) - for i, chunk in enumerate(candidates): - chunk["final_score"] = scores[i] if i < len(scores) else 0 - return sorted(candidates, key=lambda x: x["final_score"], reverse=True)[:top_n] -``` +## 4. Ranking and fusion + +Default Phase 3: + +1. BM25 order from SQLite. +2. Graph candidates appended for identifier queries. +3. Optional on-demand rerank of the top pool. +4. Exact-name boost for identifier queries. +5. Deduplicate by symbol. -## Full Sequence Diagram - -```mermaid -sequenceDiagram - participant Agent as AI Agent - participant MCP as MCP Server - participant EB as @error_boundary - participant ST as SearchTool - participant S as Searcher - participant E as Embedder - participant I as Indexer - participant R as Reranker - - Agent->>MCP: search_code(query, mode="quality", intent_hint="code") - MCP->>EB: wrap(timeout=10000, retries=2) - EB->>ST: execute() - - ST->>S: hybrid_search_async(query, limit=5) - - par Sparse search - S->>I: _bm25_search(query) - I-->>S: BM25 candidates - and Dense search - S->>E: embed_batch_async([query]) - E-->>S: query vector (768-dim) - S->>I: search_async(vector, limit=30) - I-->>S: dense candidates - end - - S->>S: RRF fusion (k=60) - S->>S: Bucket weighting (code intent) - S->>S: Co-change boost - - S->>R: rerank(query, top-15, top_n=5) - R-->>S: reranked scores - - S-->>ST: 5 final results - ST-->>EB: formatted response - EB-->>MCP: success + metadata - MCP-->>Agent: results with file paths +Optional/historical path: + +- reciprocal rank fusion of BM25, dense and FTS5 candidates; +- bucket weighting by intent and file type; +- co-change and exact-symbol boosts; +- non-code demotion (`tests/`, `docs/`, `experiments/`, `fixtures/`); +- MMR diversification; +- identifier anchors and P3 module-head anchors; +- multi-provider reranking. + +These stages are configurable and are not applied on every query. Do not use +the old pipeline description to predict the default result order. + +## 5. Documentation routing + +Routing is a single cheap comparison: + +```text +docs_max = best BM25 score among docs/** +code_max = best BM25 score among src/**, tools/**, scripts/**, tests/** +if docs_max - code_max > MSCODEBASE_DOCS_MARGIN: + use search_docs() +else: + use code search ``` -## Performance Benchmarks - -| Stage | Time | Cumulative | -|-------|:----:|:----------:| -| Query expansion | <1ms | <1ms | -| BM25 search | ~150ms | ~150ms | -| Embed query | ~800ms | ~950ms | -| LanceDB ANN | ~400ms | ~1350ms | -| RRF fusion | <1ms | ~1350ms | -| Bucket weighting | <1ms | ~1350ms | -| Co-change boost | ~50ms | ~1400ms | -| Reranker (5 candidates) | ~1200ms | ~2600ms | -| **Total (quality mode)** | **~5600ms** | | -| **Total (fast mode, BM25 only)** | **~2300ms** | | -| **Total (deep mode, recursive)** | **2-5s** | | - -> *Timings with ONNX Runtime (CPU). LM Studio (GPU) can be 3-5x faster.* - -## Configuration - -```ini -# .env search settings -DEFAULT_SEARCH_LIMIT=6 -MAX_SEARCH_RESULTS=20 -QUERY_SYNONYMS_ENABLED=true -MAX_QUERY_EXPANSIONS=3 -OVERFETCH_FACTOR=3 -RERANKER_PROVIDERS=ollama,lm_studio - -# Bucket weights (1.0 = neutral) -CODE_BUCKET_WEIGHT=1.0 -DOCS_BUCKET_WEIGHT=1.0 +Properties: + +- one extra FTS query; no model call; +- explicit `intent` wins over the heuristic; +- threshold is configurable and was tuned on the 48-query panel; +- a code query with strong documentation vocabulary can still be misrouted; + `intent="code"` is the escape hatch. + +Measured on the panel: margin `4.0` gave 0 code false positives, docs top-1 in +5/8 docs questions, and 0.792 same-basename hit@1 overall. Treat that as a +panel result, not a universal constant. + +## 6. Modes + +| Mode | Path | Intended use | +|---|---|---| +| `fast` | FTS5 + optional dense if available; no reranker | Exact names, known paths, latency-sensitive calls | +| `quality` | Default hybrid path + optional rerank | Normal code/doc questions | +| `deep` | Quality plus graph context expansion | Cross-module investigation | +| `context` | Similar-code search from a fragment | Duplicate/similar implementation lookup | +| `ask` | Retrieval plus optional local LLM generation | Only when a local LLM profile is configured | +| `auto` | Chooses a mode from query shape | Default tool entry | + +`fast` is materially less accurate on mixed natural-language questions than +`quality` on the internal panel (0.542 vs 0.688 hit@1). Use it intentionally. + +## 7. Optional layers + +| Layer | Flag / condition | Effect | +|---|---|---| +| Dense retrieval | `MSCODEBASE_DENSE_OFF=false` + provider | Adds vector candidates | +| On-demand rerank | `MSCODEBASE_ONDEMAND_RERANK=true` + provider | Re-scores top candidates | +| P3 module-head anchors | `MSCODEBASE_P3_ANCHOR=true` | Adds module-head chunks | +| RU synonyms | `MSCODEBASE_RU_SYNONYMS=true` | Expands Russian query variants | +| Non-code demotion | `MSCODEBASE_DEMOTE_NON_CODE=true` | Down-weights tests/docs/experiments | +| Intent-aware demotion | `MSCODEBASE_DEMOTE_INTENT_AWARE=true` | Keeps docs for docs intent | +| Docs auto-route | `MSCODEBASE_DOCS_MARGIN` | Docs/code channel choice | + +## 8. Output contract + +Each result carries enough metadata for the agent to explain itself: + +- `file`, `chunk_index`, `start_line`, `end_line`; +- `symbol_name`, `symbol_kind`, `layer`; +- `final_score` and `source` (`sqlite_fts5`, `graph`, `docs_fts`, ...); +- optional `docstring`, callers/callees and security flags. + +The tool response may include a stale-index warning. A warning is not a search +failure; it means the agent should refresh the index before trusting freshness. + +## 9. Measured behavior + +Internal panel: 48 real queries, clean index, `experiments/**` excluded, graph +present. + +| Path | hit@1 | hit@5 | +|---|---:|---:| +| Code, quality | 0.825 | 0.900 | +| Docs, exact path | 0.000 | 0.125 | +| Docs, same basename | 0.750 | 0.750 | +| Overall quality | 0.688 | 0.750 | +| Overall auto docs route | 0.792 | 0.875 | +| Fast | 0.542 | 0.729 | + +Limits: + +- these are internal, not a universal benchmark; +- docs exact-path scoring undercounts correct translations; +- no static configuration or tested local reranker reached 95% hit@1; +- a two-channel repo map reached about 40/62/73% gold-file coverage at + 1k/2k/4k tokens with perfect routing, and PageRank alone was lower. + +## 10. Debugging search + +```text +debug_runtime_passport() +intel_get_runtime_status() +codebase(action="index", path="status") +search_code(query="...", explain=true) ``` + +Useful checks: + +1. Is `chunks.db` non-empty? If not, run `scripts/reindex_text_only.py`. +2. Is `graph.db` empty? Run `scripts/rebuild_graph.py`. +3. Does the query look like docs? Try `intent="docs"` or `intent="code"`. +4. Compare exact-path vs same-basename before calling a docs result a miss. +5. For `fast` vs `quality`, run the same query in both modes and compare. + +## 11. Non-goals + +- An LLM router for every query. Routing is lexical and cheap. +- A static repo map that replaces iterative code search. It is context + selection, not an oracle. +- Claiming 95% retrieval accuracy from a small internal panel. +- Hiding an empty graph or stale index behind a confident answer. diff --git a/docs/ru/ARCHITECTURE.md b/docs/ru/ARCHITECTURE.md index d28e3a06..2a4f90bf 100644 --- a/docs/ru/ARCHITECTURE.md +++ b/docs/ru/ARCHITECTURE.md @@ -1,673 +1,243 @@ -[🇬🇧 English](../en/ARCHITECTURE.md) • [🇷🇺 Русский](ARCHITECTURE.md) • [🇨🇳 中文](../zh/ARCHITECTURE.md) +[English](../en/ARCHITECTURE.md) · [Русский](ARCHITECTURE.md) · [中文](../zh/ARCHITECTURE.md) # MSCodeBase Intelligence — Архитектура -> **Версия:** 3.5.0 -> **Последнее обновление:** 2026-08-12 -> **Архитектура:** 4-слойная архитектура + Graph-Native PropertyGraph Layer + Data Flow Layer (Точки входа → MCP Server/DI → Tool Classes → Core Business Logic → PropertyGraph → Data Flow) с Multi-Window Registry + DocSync +> **Актуально на 2026-10-25.** Документ описывает дефолтный Phase 3 runtime: +> SQLite FTS5/BM25 плюс PropertyGraph. Эмбеддинги, реранкинг и LanceDB/vector +> path опциональны и отмечены явно. --- -## Содержание - -1. [Основные принципы](#1-core-principles) -2. [Слойная архитектура](#2-layer-architecture) -3. [DI-контейнер (ServiceCollection)](#3-di-container) -4. [Слой инструментов (32 core + 16 intel + 13 inline + 4 dev = 65 всего)](#4-tool-layer) -5. [PropertyGraph (v3.0)](#5-propertygraph-layer-v30) -6. [Cypher Query Engine (v3.0)](#6-cypher-query-engine-v30) -7. [Обработка ошибок](#7-error-handling) -8. [Rate Limiting и отказоустойчивость](#8-rate-limiting--resilience) -9. [Поток данных: Запрос → Ответ](#9-data-flow) -10. [Обогащение метаданных (v2.4.4+)](#10-metadata-enrichment-v244) -11. [Особенности Windows](#11-windows-specifics) -12. [Multi-Window Registry (v2.3+)](#12-multi-window-registry-v23) -13. [Стратегия тестирования](#13-testing-strategy) -14. [Архитектурные инварианты](#14-architectural-invariants) +## 1. Цели дизайна + +Сервер даёт AI-ассистенту обоснованные локальные ответы о кодовой базе, не +делая удалённый сервис или большую модель обязательными. + +1. **Local-first.** Поиск, граф и память проекта работают без сети. +2. **Deterministic before probabilistic.** Точные символы, пути и BM25 + проверяются раньше опциональных semantic-слоёв. +3. **Fail open, not silent.** Нет embedder, пустой граф или stale index должны + давать диагноз, а не выдуманный ответ. +4. **Один контракт для людей и агентов.** Одна tool schema для Zed, скриптов и + тестов. +5. **Observable.** Runtime passport, index status, health report и logs — + полноценные инструменты, не debug-приложение. + +## 2. Runtime в двух словах + +```text +Zed + ├── built-in LSP (autocomplete, diagnostics) + └── MCP client + │ + ▼ + src/main.py (stdio) или src/remote_main.py (HTTP) + │ + ▼ + server_factory.create_mcp_server() + ├── create_service_collection() DI container + ├── register_all_tools() 64 registered tools + └── notification / extension hooks + │ + ├── Search core/search + ├── Index core/indexing + ├── Graph core/graph.py + core/search/graph_adapter.py + ├── Memory core/intelligence + ├── Writes core/modification_guard.py + mcp/tools/write_tools.py + └── Optional providers/embedder, providers/reranker, LSP client + │ + ▼ + Storage + ├── /.mscodebase/chunks.db SQLite + FTS5 + ├── /projects//graph.db PropertyGraph + ├── /projects//intelligence/ memory, incidents + └── /logs/ server logs +``` + +`` — `%LOCALAPPDATA%/mscodebase` на Windows или платформенный +эквивалент. Артефакты проекта лежат вне репозитория; `chunks.db` — единственный +store внутри директории проекта. + +## 3. Слои + +| Слой | Ответственность | Основные модули | +|---|---|---| +| Entry | Старт процесса, project root, stdio/HTTP | `src/main.py`, `src/remote_main.py` | +| Protocol | MCP server, lifecycle, hooks | `src/mcp/server.py`, `server_factory.py` | +| Tool | Тонкие адаптеры: валидация, формат, error boundary | `src/mcp/tools/*.py`, `server_tools.py` | +| Core | Search, indexing, graph, memory, write guards | `src/core/*` | +| Providers | Опциональные embedders, rerankers, LSP | `src/providers/*`, `src/core/lsp_client.py` | +| Storage | SQLite/FTS5, PropertyGraph, JSON memory, logs | `chunk_store.py`, `graph.py`, `intelligence/` | + +Tool layer не реализует ranking или parsing сам. Он резолвит project-scoped +service и делегирует в core. + +## 4. Точки входа и lifecycle + +- `src/main.py` — stdio MCP server для Zed extension. Определяет extension/ + project root, настраивает logging, запускает server loop. +- `src/remote_main.py` — тот же сервер через authenticated HTTP transport. + Опционален и выключен без конфигурации. +- `src/mcp/server_factory.py` — создаёт сервер, DI container, регистрирует + tools, notifications и shutdown handlers. +- `src/mcp/server_tools.py` — регистрация и allowlist. + +Второй проект в другом окне Zed изолируется `ProjectIndexerRegistry`: у каждого +проекта свои indexer/searcher/graph handles, LRU-лимит и resource monitoring. + +## 5. Dependency injection + +`src/core/di_container.py` даёт `ServiceCollection` и +`create_service_collection(project_root)`. Сервисы — singletons в рамках +процесса: + +- project root и parser; +- indexer/searcher и опциональный embedder; +- PropertyGraph и symbol index; +- `ProjectIndexerRegistry` / `MultiProjectSearcher`; +- rate limiter, circuit breaker, notification broker. + +Tool classes получают collection через constructor injection. Они не создают +indexer и не открывают БД напрямую. Shutdown закрывает ресурсы и фоновые задачи. + +## 6. Tool layer + +Сервер регистрирует **64 инструмента**: + +- 31 core tool class (search, LSP, graph, analysis, write, lifecycle); +- 16 `intel_*`; +- 13 inline diagnostic; +- 4 dev. + +`execute_script` — 65-й опциональный инструмент, только при +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. Default allowlist показывает **49**; +`MSCODEBASE_MCP_TOOLS` управляет видимостью (пусто — все зарегистрированные). + +`codebase(action=...)` — hub над index, git и write. У поиска есть +`search_code` и `plan_search`, который возвращает детерминированный план +(primary query, exact tokens, symbols, concepts, intent). + +Все tools обёрнуты в `error_boundary`: timeout, telemetry и structured error +вместо зависшего MCP loop. + +## 7. Indexing pipeline + +```text +discover files + → FileGuard (extensions, size, binaries, system dirs, .gitignore) + → parser (Tree-sitter; language-specific extractors) + → chunks + symbols + calls/imports/assignments/decorators/overrides + → SQLiteChunkStore (text + FTS5) and PropertyGraph +``` + +Правила: + +- `experiments/**` **исключён из индекса**. Там frozen-панели и run artifacts; + их индексация заставляла BM25 возвращать саму панель вместо кода. Файлы + остаются в git. +- Incremental indexing обновляет chunks и graph rows файла одной транзакцией. +- Полный vector reindex намеренно блокируется при + `MSCODEBASE_DENSE_OFF=true`. Используйте `scripts/reindex_text_only.py` для + текста и `scripts/rebuild_graph.py` для графа. +- `build_head` пишется только после полного успешного индекса, чтобы freshness + можно было сверить с git HEAD. +- `FileGuard` и `SafePathManager` отсекают пути вне проекта и системные + директории до parsing. + +## 8. Storage + +| Store | Путь | Назначение | +|---|---|---| +| Chunk store | `/.mscodebase/chunks.db` | Текст, metadata, FTS5 | +| Graph | `/projects//graph.db` | Символы, файлы, typed edges | +| Memory | `/projects//intelligence/` | Память, инциденты, jobs | +| Logs | `/logs/` | Диагностика сервера и tools | ---- - -## 1. Основные принципы - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Четыре слоя архитектуры │ -│ │ -│ Слой 1: main.py (Точки входа, минималистичные) │ -│ Слой 2: mcp/server.py (DI-маршрутизация, регистрация) │ -│ Слой 3: mcp/tools/*.py (32 core + 13 inline + 4 dev) │ -│ Слой 4: core/*.py (Чистая бизнес-логика) │ -└──────────────────────────────────────────────────────────────────┘ -``` - -**Ключевые правила:** -- **Слой Core НЕ имеет MCP-импортов.** Это чистый Python с бизнес-логикой. -- **Слой инструментов НИКОГДА не создаёт зависимости.** Всё приходит из DI. -- **server.py ТОЛЬКО регистрирует** — никакой логики, форматирования, try/except. -- **Зависимости направлены вниз:** Main ← Server ← Tools ← Core. - ---- - -## 2. Слойная архитектура - -### 2.0 Десятислойная runtime-архитектура (v2.4) - -``` - Слой 0: Filesystem — какие файлы есть на диске? - Слой 1: SystemArtifacts — это системный путь? - Слой 2: Bridge (LSP→MCP) — [deprecated: LSP server removed 2026-07-20] - Слой 3: Registry (IndexerRegistry) — какой Indexer принадлежит проекту? - Слой 4: StateMachine (ProjectState) — в каком состоянии проект? - Слой 5: RuntimeCoordinator — можно ли выполнять запрос? - Слой 6: ProjectContext — как выглядит проект сейчас? - Слой 7: Passport — какой процесс сейчас работает? - Слой 8: Intel Layer — что делать с информацией? - Слой 9: MCP Tools / AI Agent — ответ пользователю -``` - -**Поток данных:** -``` -Filesystem → SystemArtifacts → Bridge → Registry → StateMachine - ↓ -MCP Tools ← Intel Layer ← ProjectContext ← RuntimeCoordinator -``` - -**Ключевое правило:** Инструмент НЕ обращается к Registry, Bridge или Passport напрямую. -Всё — через `RuntimeCoordinator.can_execute()` + `ProjectContext.capture()`. - -### 2.1 Точки входа - -| Файл | Протокол | Назначение | -|------|----------|------------| -| `src/main.py` | MCP STDIO | AI-ассистент в Zed Chat | -| `src/lsp_main.py` *(удалён)* | LSP STDIO | Заменён LSP-клиентом `src/core/lsp_client.py` | - -Оба используют одну и ту же фабрику `create_service_collection()`. - -### 2.2 MCP-сервер - -`src/mcp/server.py` — **~68 строк** (было 3 100 до рефакторинга). - -Обязанности: -1. Определить корень проекта (`resolve_project_root()`) -2. Создать DI-контейнер (`create_service_collection()`) -3. Зарегистрировать 32 core + 16 intel + 13 inline + 4 dev = 65 всего -4. Зарегистрировать system prompt (mscodebase-rules) - -**Здесь нет бизнес-логики.** Каждый инструмент — импорт из `mcp/tools/`. - -### 2.3 Слой инструментов - -`src/mcp/tools/*.py` — **32 core (25 + codebase hub + 6 LSP) + 13 inline + 4 dev.** - -Каждый инструмент: -- Наследуется от `MCPTool` (ABC) -- Получает зависимости через конструктор (Constructor Injection) -- Имеет ровно одну точку входа: `async def execute(**kwargs) -> dict` -- Декорирован `@error_boundary(tool_name, timeout_ms)` - -```python -class SearchCodeTool(MCPTool): - """search_code — семантический поиск по коду.""" - - def __init__(self, services: ServiceCollection): - super().__init__(services, tool_name="search_code") - self.searcher = services.resolve(Searcher) - self.symbol_index = services.resolve(SymbolIndex) - - @error_boundary("search_code", timeout_ms=15000) - async def execute( - self, - query: str, - mode: str = "auto", - limit: int = 6, - kwargs: Optional[Dict[str, Any]] = None, - ) -> dict: - self.require_index() # проверка готовности индекса - # ... логика поиска -``` - -### 2.4 Слой ядра - -`src/core/*.py` — **54 файла верхнего уровня** (123 с подпакетами) чистой бизнес-логики. - -Ключевые модули: - -| Модуль | Путь | Назначение | -|--------|------|------------| -| `di_container.py` | `src/core/di_container.py` | DI-контейнер (14 сервисов) | -| `error_handler.py` | `src/core/error_handler.py` | ToolError + error_boundary | -| `rate_limiter.py` | `src/core/rate_limiter.py` | DebounceBatch + CircuitBreaker | -| `engine.py` | `src/core/search/engine.py` | Гибридный поиск (BM25 + Dense + FTS5 + RRF) | -| `graph.py` | `src/core/graph.py` | PropertyGraph — SQLite граф свойств | -| `graph_adapter.py` | `src/core/search/graph_adapter.py` | SymbolIndexAdapter — обёртка PropertyGraph | -| `cypher_engine.py` | `src/core/search/cypher_engine.py` | Cypher→SQL для PropertyGraph | -| `indexer.py` | `src/core/indexing/indexer.py` | LanceDB векторное хранилище + пайплайн индексации | -| `symbol_index.py` | `src/core/indexing/symbol_index.py` | Граф вызовов (BFS, PageRank) | -| `parser.py` | `src/core/indexing/parser.py` | Tree-sitter AST-парсер (32 расширения: core + shell + context + markdown) | -| `file_guard.py` | `src/core/indexing/file_guard.py` | .gitignore + фильтр расширений | -| `db_manager.py` | `src/core/indexing/db_manager.py` | Жизненный цикл таблиц LanceDB (PID-lock, reindex guard) | -| `fts5_mixin.py` | `src/core/search/fts5_mixin.py` | FTS5-миксин полнотекстового поиска | -| `scoring.py` | `src/core/search/scoring.py` | RRF + MMR diversity scoring | -| `layer.py` | `src/core/intelligence/layer.py` | Intel Layer (16 intel_* инструментов) | -| `runtime_coordinator.py` | `src/core/runtime_coordinator.py` | ExecutionVerdict + can_execute() | -| `project_context.py` | `src/core/intelligence/project_context.py` | Снэпшот состояния проекта | -| `llama_runner.py` | `src/providers/reranker/llama_runner.py` | Жизненный цикл llama-server.exe (реранкер) | -| `remote_embedder.py` | `src/providers/embedder/remote_embedder.py` | Эмбеддер llama.cpp GGUF (primary) + ONNX INT8 / LM Studio / Ollama fallback | -| `doc_sync_engine.py` | `src/core/doc_sync_engine.py` | Автосинхронизация доков с кодом (rename hook) | - -### 2.5 Search Engine (v3.3) - -``` -┌─────────────────────────────────────────────────────────┐ -│ Search Pipeline (engine.py) │ -│ │ -│ query_str → embed() → BM25 → FTS5 → 3-way RRF → MMR │ -│ ↕ │ -│ MultiSignalScorer: api_signature, graph_diffusion, │ -│ module_proximity, cochange_boost │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ PropertyGraph (graph.py) │ -│ SQLite (WAL + mmap), nodes/edges, JSON properties │ -│ — 15 меток узлов (File, Function, Class, Variable...) │ -│ — 29 типов рёбер (CALLS, DEFINES, ASSIGNED_FROM, ...) │ -│ — Cypher query engine (MATCH→SQL) │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Data Flow Layer (v3.2.0) │ -│ │ -│ 1. Unified Walker — один Tree-sitter parse → вызовы + │ -│ присваивания. Parse cache с хэшем содержимого. │ -│ 2. Conditional Flow — ASSIGNED_FROM рёбра содержат │ -│ condition_path (if/for/while/try/except) │ -│ 3. Только внутри процедур — в пределах тела функций │ -│ 4. 16 языков: Python, Rust, TS, TSX, Go, JS, │ -│ Java, C#, Ruby, PHP, Kotlin, Swift, C, C++, │ -│ Scala, Dart │ -└─────────────────────────────────────────────────────────┘ -``` - -### 2.6 Эмбеддер: llama.cpp GGUF (native, предпочтительно), ONNX INT8 — fallback - -MCP-сервер теперь использует multilingual-e5-small через **llama.cpp GGUF** (`llama-server.exe`) как основной эмбеддер; **ONNX Runtime (CPU, in-process)** — fallback, когда llama.cpp недоступен (автоопределение при старте: ONNX-предзагрузка отменяется, как только llama.cpp поднялся): - -- **Модель**: `intfloat/multilingual-e5-small` (384-dim), GGUF; fallback — INT8 ONNX -- **Runtime**: llama.cpp (`:8080`, llama-server); fallback — ONNX in-process -- **Архитектура**: llama.cpp — отдельный процесс; ONNX in-process только как fallback -- **Производительность**: ~16-50 ch/s (llama.cpp CPU, зависит от нагрузки) -- **RAM**: ~1.7 GB (2× llama-server: embed + reranker); ONNX fallback ~265 MB -- **Конфиг**: модель определяется автоматически (GGUF из `models/`); legacy `EMBEDDING_DIMENSION`/`EMBEDDING_PROVIDER` не используются - -Реранкер по-прежнему работает через llama-server (1 процесс, не 2). - -Legacy fallback-провайдеры (LM Studio, Ollama, remote ONNX) остаются доступны через `remote_embedder.py` для кастомных настроек. - ---- - -## 3. DI-контейнер - -### 3.1 ServiceCollection - -```python -# src/core/di_container.py - -services = ServiceCollection() - -# Регистрация синглтона: -services.add_singleton(Indexer, indexer_instance) - -# Регистрация ленивой фабрики: -services.add_factory(Searcher, lambda s: Searcher(s.resolve(Indexer), ...)) - -# Разрешение: -indexer = services.resolve(Indexer) # каждый раз тот же экземпляр -``` - -### 3.2 Зарегистрированные сервисы (11) - -| # | Сервис | Тип | Создаётся | -|---|--------|-----|-----------| -| 1 | ProjectRootKey | singleton | `project_root` (sentinel-ключ) | -| 2 | CodeParser | singleton | `CodeParser()` | -| 3 | RemoteEmbedder | singleton | `RemoteEmbedder()` | -| 4 | ProjectRegistry | singleton | `ProjectRegistry()` | -| 5 | MultiProjectSearcher | singleton | `MultiProjectSearcher(embedder, registry)` | -| 6 | PropertyGraph | singleton | `PropertyGraph(.codebase/graph.db)` | -| 7 | ProjectIndexerRegistry | singleton | `ProjectIndexerRegistry(max_cached=5)` | -| 8 | NotificationBroker | singleton | `NotificationBroker()` | -| 9 | IndexerFactoryKey | factory | `_create_indexer_for_path` | -| 10 | SlidingWindowRateLimiter | singleton | `SlidingWindowRateLimiter()` | -| 11 | CircuitBreaker | singleton | `CircuitBreaker(name="lm_studio")` | - -> Чистка (Задача 2/5): удалены мёртвые регистрации DbPathKey, FileGuard, -> SymbolIndex, ResourceMonitor, ResourceMonitorKey (никогда не резолвились). - ---- - -## 4. Слой инструментов - -### 4.1 Регистрация инструментов - -В `src/mcp/server_tools.py`: - -```python -def register_all_tools(mcp, services): - tool_classes = [ - # Search (3) - SearchCodeTool, GetSymbolInfoTool, ImpactAnalysisTool, - # Hub: codebase (write/index/git/notify — мультиплексирован по action) - CodebaseTool, - # Spoke: AST Sandbox (3 слоя: AST валидация + runtime __import__ wrapper + subprocess изоляция) - ExecuteScriptTool, - # Analysis (5) - StructuralSearchTool, GetRepoMapTool, GetRepoRankTool, - ScanChangesTool, GenerateChunkSummariesTool, - # Graph (3 — Фаза 2: graph_query мультиплексирует cypher + related + flow) - CrossRepoSearchTool, CrossProjectDepsTool, GraphQueryTool, - # Investigation (3) - GetBugCorrelationTool, GetHotspotsTool, FindSimilarBugsTool, - # Lifecycle (4) - SubmitBackgroundTaskTool, GetTaskStatusTool, VerifyActionTool, GetActionReceiptTool, - ] - # +16 intel_* инструментов + 13 inline diagnostic + 4 dev - # Всего: 65 зарегистрировано (32 core + 16 intel + 13 inline + 4 dev) -``` - -**Фильтр видимости инструментов:** по умолчанию видим набор из allowlist `MSCODEBASE_MCP_TOOLS` (см. `src/mcp/server_tools.py`). Установите `MSCODEBASE_MCP_TOOLS=""`, чтобы показать все 65 (+1 `execute_script` при `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`). - -### 4.2 Все инструменты по группам - -| Группа | Файл | Инструменты | -|--------|------|-------------| -| **Поиск** (3) | `search_tools.py` | search_code, get_symbol_info, impact_analysis | -| **LSP** (6) | `lsp_tools.py` | lsp_find_references, lsp_find_definition, lsp_document_symbols, lsp_get_type_info, lsp_get_diagnostics, lsp_get_code_actions | -| **Hub: codebase** (1) | `codebase_tool.py` | codebase(action=index/git/rename/move/delete/replace/insert/notify) | -| **Spoke: Sandbox** (1) | `codebase_tool.py` | execute_script(code) — AST + runtime import wrapper + subprocess изоляция (опционально, env-флаг) | -| **Анализ** (5) | `analysis_tools.py` | structural_search, get_repo_map, get_repo_rank, scan_changes, generate_chunk_summaries | -| **Граф** (4) | `graph_tools.py` | cross_repo_search, cross_project_deps, graph_query, detect_communities | -| **Расследование** (3) | `investigation_tools.py` | get_bug_correlation, get_hotspots, find_similar_bugs | -| **Дубликаты** (1) | `duplication_tool.py` | find_duplicates | -| **Контекст** (1) | `context_tool.py` | get_context | -| **Жизненный цикл** (3) | `lifecycle_tools.py` | submit_background_task, get_task_status, verify_action | -| **Doc** (1) | `doc_tools.py` | stale_detector | -| **Dev** (4) | `dev_tools.py` | generate_docs, bump_version, auto_update_docs, install_git_hooks | -| **Intelligence** (14) | `intelligence/tools_reg.py` | intel_get_runtime_status, intel_trigger_reindex, intel_reset_index, intel_get_job_status, intel_code_topology, intel_log_incident, intel_get_project_memory, intel_add_memory_node, intel_auto_collect_adrs, intel_get_hotspots, intel_analyze_incident, intel_predict_root_cause, intel_get_telemetry, intel_retract_memory_node | -| **Diagnostic inline** (13) | `server_tools.py` | debug_runtime_passport, intel_get_project_context, intel_explain_project_state, get_runtime_counters, intel_tool_health, intel_execution_timeline, refresh_db_connection, notify_change, read_live_file, get_logs, get_health_report, dual_arm_health_check, ack_impact | - -> **Всего:** 65 зарегистрировано (32 core + 16 intel + 13 inline + 4 dev). По умолчанию видим набор из allowlist `MSCODEBASE_MCP_TOOLS` (см. `src/mcp/server_tools.py`). Показать все: `MSCODEBASE_MCP_TOOLS=""`. - -## 5. Обработка ошибок - -### 5.1 Декоратор error_boundary - -Каждый инструмент обёрнут в `@error_boundary`: - -```python -@error_boundary("tool_name", timeout_ms=15000, max_retries=1) -async def execute(self, **kwargs) -> dict: - ... -``` - -Гарантирует: -1. **Реальный таймаут** через `asyncio.wait_for(timeout_ms / 1000.0)` -2. **Унифицированный JSON** всегда: `{"status": "ok"|"error"|"timeout"|"warning", "message": "...", "detail": "...", "latency_ms": 123}` -3. **Контролируемые ошибки** (`ToolError`) → возврат как есть, без повтора -4. **Неожиданные ошибки** → логируются с полным traceback, возвращаются как `"status": "error"` -5. **Повтор при таймауте** — настраивается через `max_retries` - -### 5.2 Иерархия ToolError - -```python -ToolError # Базовый: status, message, detail, recoverable -├── IndexNotReadyError # Индекс пуст (warning, recoverable) -└── RateLimitError # Превышен rate limit (warning, recoverable) -``` - ---- - -## 6. Rate Limiting и отказоустойчивость - -### 6.1 SlidingWindowRateLimiter - -```python -limiter = SlidingWindowRateLimiter() # asyncio.Lock для thread safety - -ok = await limiter.acquire("notify_change", max_per_sec=10.0) -if not ok: - raise RateLimitError(detail="Слишком много вызовов notify_change") -``` - -### 6.2 DebounceBatch - -Заменяет немедленный `searcher.reindex()` при каждом изменении файла: - -```python -batch = DebounceBatch(callback=searcher.reindex, config=DebounceConfig( - debounce_ms=500, # 500ms после последнего события - max_batch_size=100, # или при 100 файлах — немедленный сброс - max_wait_ms=5000, # защита от бесконечного debounce -)) -await batch.add("file.py") # BM25 перестроится через 500ms (или при 100 файлах) -``` - -### 6.3 CircuitBreaker - -```python -cb = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0, name="lm_studio") - -result = await cb.call( - lambda: embedder.embed_batch(texts), - fallback={"status": "fallback", "message": "LM Studio недоступен"} -) -# Состояния: CLOSED → OPEN (5 ошибок) → HALF_OPEN (через 30s) → CLOSED (успех) -``` - ---- +LanceDB — legacy/optional vector store. Дефолтный text-first путь её не требует +и не использует как основной источник candidates при +`MSCODEBASE_SQLITE_CHUNKS=true`. + +## 9. Интеграция поиска + +Поиск — крупнейшая core-подсистема. Дефолтный путь: + +1. SQLite FTS5/BM25 candidates; +2. опциональный symbol/graph stage для identifier-shaped запросов; +3. опциональный on-demand rerank; +4. exact-name boost и symbol deduplication; +5. docs routing на уровне tool при сильном BM25-разрыве. + +Полный RRF/MMR/anchor pipeline в `core/search/engine.py` относится к +опциональному dense/LanceDB path или старым путям. Он не применяется молча к +каждому Phase 3 запросу. Точное разделение — в +[SEARCH_PIPELINE.md](SEARCH_PIPELINE.md). + +## 10. Intelligence и память + +`core/intelligence/` реализует память проекта и инциденты: + +- ADR и known issues хранятся как записи со статусом и evidence; +- инциденты можно логировать, искать и предсказывать по runtime data; +- `verify-on-read` помечает факты как verified/refuted/superseded; +- retraction/supersede сохраняют историю вместо удаления. -## 7. Поток данных - -``` -Zed AI Agent - │ - ▼ -MCP Tool Call (например, search_code("find indexer")) - │ - ▼ -error_boundary decorator - ├── проверка таймаута (asyncio.wait_for) - ├── проверка rate limit (SlidingWindowRateLimiter) - └── выполнение инструмента - │ - ▼ - MCPTool.execute(**kwargs) - │ - ├── self.require_index() → IndexNotReadyError если пуст - ├── services.resolve(Searcher) - ├── engine.hybrid_search(query) - │ │ - │ ▼ - │ core/search/engine.py - │ ├── BM25 search (in-memory TF-IDF) - │ ├── Vector search (LanceDB + llama.cpp GGUF, ONNX fallback) - │ ├── FTS5 search (SQLite FTS5, trigram+porter) - │ └── 3-way RRF fusion + MMR diversity - │ - └── return {"status": "ok", "results": [...]} - │ - ▼ - error_boundary → {"status": "ok", ...latency_ms} - │ - ▼ - Zed Chat (форматированный JSON-ответ) -``` - ---- - -## 8. Обогащение метаданных (v2.4.4+) - -### 8.1 Метаданные чанка - -Каждый чанк в LanceDB хранит 6 полей метаданных для детерминированной -фильтрации и multi-granularity retrieval: - -| Поле | Тип | Пример | Назначение | -|------|-----|--------|------------| -| `layer` | string | `"core"` | Архитектурный слой: core/mcp/utils/tests/... | -| `module_name` | string | `"core.parser"` | Логическое имя модуля из пути файла | -| `hierarchy_level` | string | `"method"` | Уровень: function/method/class/impl/lines | -| `is_public` | bool | `true` | Публичный/приватный (`_`-префикс) | -| `symbol_type` | string | `"method_definition"` | AST-тип узла | -| `parent_id` | string | md5-хеш | Детерминированный хеш родителя | - -Определение слоя — автоматическое, по пути файла: - -| Путь | layer | -|------|-------| -| `src/core/*` | `core` | -| `src/mcp/tools/*` | `mcp_tools` | -| `src/mcp/*` | `mcp` | -| `src/utils/*` | `utils` | -| `tests/*` | `tests` | -| `docs/*` | `docs` | -| `.agents/*` | `agents` | -| `scripts/*` | `scripts` | -| `.github/*` | `ci` | -| прочее | `root` | - -### 8.2 Flat Tree Hierarchy (SproutRAG-style) - -`parent_id` — детерминированный md5-хеш: - -- **Для метода:** `md5(file_path + "::" + class_name)` — parent = класс -- **Для функции:** `md5(file_path)` — parent = модуль -- **Для части гигантской функции:** `md5(file_path + "::" + symbol_name)` — parent = функция - -Позволяет делать multi-granularity retrieval без графовой БД: -- Найти все методы класса → `get_chunks_by_parent_id("md5_hash")` -- Подняться до модуля → aggregation по parent_id - -### 8.3 Фильтрация по слою в search_code - -```python -# Только core-слой -search_code(query="DI container", filter_layer="core") - -# Только tests -search_code(query="test_parser", filter_layer="tests") - -# Без фильтра (все слои, как раньше) -search_code(query="parser") -``` - -Фильтрация по слою работает на уровне LanceDB через `.where(prefilter=True)` — векторный поиск идёт только по чанкам указанного слоя. BM25 пост-фильтруется по layer из metadata. - ---- - -## 9. Особенности Windows - -### 9.1 Разрешение путей - -`PROJECT_PATH` может содержать литерал `$ZED_WORKTREE_ROOT` (env var не разрешается Zed на Windows). -Решение: `resolve_project_root()` проверяет 7 fallback-стратегий: - -1. Переданный аргумент -2. LSP→MCP bridge (временный файл от LSP, который знает `root_uri`) -3. `PROJECT_PATH` env var (разрешается, если не `$ZED`) -4. `ext_root`, если это git-репозиторий -5. `ZED_WORKTREE_ROOT` env var -6. CWD (из Zed `settings.json`) -7. `ext_root` как финальный fallback - -### 9.2 Безопасность git-подпроцессов - -```python -env["GIT_TERMINAL_PROMPT"] = "0" # Нет интерактивных запросов -env["GIT_ASKPASS"] = "echo" # Нет credential helper -env["GIT_PAGER"] = "cat" # Нет пейджера -creationflags = subprocess.CREATE_NO_WINDOW # Нет консольного окна -``` - -### 9.3 Поддержка длинных путей - -SafePathManager использует `to_win_long_path()` (добавление `\\?\`) для путей длиннее 260 символов. - ---- - -## 10. Multi-Window Registry (v2.3+) - -v2.3+ поддерживает **несколько открытых проектов в Zed одновременно**. -Раньше DI хранил singleton `Indexer` — при переключении окон state ломался -(один `file_guard`, один `db_path`, общий `SymbolIndex`). - -### 10.1 `ProjectIndexerRegistry` - -`src/core/indexing/project_indexer_registry.py` — потокобезопасный реестр объектов `Indexer`: - -```python -registry = ProjectIndexerRegistry( - max_cached=5, # LRU лимит (5 проектов = 1-2.5GB RAM) - resource_monitor=get_global_resource_monitor(), # adaptive throttling -) - -# Per-project ленивое создание через factory: -def _create_indexer(p: Path) -> Indexer: - return Indexer( - db_path=_generate_unique_db_path(p), - file_guard=FileGuard(p), - symbol_index=SymbolIndex(), # изолирован - project_path=p, ... - ) - -services.add_singleton(IndexerFactoryKey, _create_indexer) -indexer = registry.get_indexer(project_path, factory=_create_indexer) -``` - -**Гарантии:** -- **Изоляция:** каждое окно получает свой `FileGuard`/`SymbolIndex`/`db_path`. -- **LRU:** при открытии 6-го проекта самый старый `Indexer` вытесняется. -- **Pressure-evict:** при RAM > 1GB или CPU > 85% — принудительный evict - **перед** созданием нового `Indexer` (предотвращает OOM). -- **Cleanup:** `_safe_close()` обнуляет LanceDB connection + `gc.collect()` - (для Windows mmap handles). - -### 10.2 `ResourceMonitor` - -`src/core/indexing/resource_monitor.py` — stdlib-only мониторинг (без `psutil`): - -| Платформа | Метод | -|-----------|-------| -| POSIX | `resource.getrusage(RUSAGE_SELF).ru_maxrss` | -| Windows | `psapi.GetProcessMemoryInfo` через `ctypes` | -| CPU | `resource.getrusage` utime+stime delta / wall-clock | - -**Пороги:** -- Soft: 768MB / 75% CPU → throttle индексации (0.1s задержка между файлами) -- Hard: 1024MB / 85% CPU → pressure-evict + 0.5-2s задержка - -```python -monitor = get_global_resource_monitor() -snap = monitor.sample() # ResourceSnapshot (rss_mb, cpu_percent, threads) - -if monitor.is_under_pressure(): - delay = monitor.suggest_throttle_delay_sec() - time.sleep(delay) # в Indexer.index_project между файлами -``` - -### 10.3 Per-workspace DI (multi-window) - -Standalone LSP (`src/lsp_main.py`) **удалён** (WONTFIX, см. -`docs/en/investigations/LSP_WONTFIX.md`). Per-workspace DI живёт в -`ProjectIndexerRegistry` (v2.3+): - -```python -registry: ProjectIndexerRegistry = services.resolve(ProjectIndexerRegistry) -indexer = registry.get_indexer(project_path, factory=factory) -# → создаёт изолированный per-project Indexer (LRU max_cached=5 + pressure eviction) -``` - -Каждое окно/workspace получает собственный Indexer с собственной LanceDB БД -и PropertyGraph (пути хэшируются вне проекта — Задача 4/5). - -### 10.4 MCP `resolve_indexer_for_request` - -`src/mcp/tools/base.py` — единая точка получения per-project Indexer: - -```python -def resolve_indexer_for_request(services, explicit_project_root=None): - target = explicit_project_root or resolve_project_root() or DI_default - registry = services.resolve(ProjectIndexerRegistry) - factory = services.resolve(IndexerFactoryKey) - return registry.get_indexer(target, factory=factory) - -class MCPTool: - def resolve_indexer(self, project_root=None): - return resolve_indexer_for_request(self._services, project_root) -``` - -**Все MCP-инструменты** должны использовать `self.resolve_indexer(...)` -вместо `self._services.resolve(Indexer)` — последний больше не работает -(Indexer не singleton). - -### 10.5 HealthReport `_check_resources` - -`src/core/code_health.py` — добавлен метод: - -```python -def _check_resources(self): - summary = get_global_resource_monitor().get_summary() - self.metrics["process_rss_mb"] = summary["rss_mb"] - self.metrics["process_cpu_percent"] = summary["cpu_percent"] - self.metrics["registry_cached_projects"] = ... - self.metrics["registry_evictions"] = ... - if summary["under_hard_pressure"]: - self.issues.append({...}) -``` - ---- - -## 11. Стратегия тестирования - -``` -tests/ -├── test_error_handler.py # 18 тестов — ToolError, error_boundary -├── test_rate_limiter.py # 21 тестов — SlidingWindow, DebounceBatch, CircuitBreaker -├── test_di_container.py # 13 тестов — ServiceCollection, 11 services -├── test_resource_monitor.py # 11 тестов — ResourceMonitor + ProjectIndexerRegistry (v2.3+) -├── test_parser.py # 4 теста — Tree-sitter парсинг -├── test_execution_contract.py# 10 тестов — verify_action -├── test_task_queue.py # 6 тестов — очередь фоновых задач -├── test_branch_aware_index.py# 8 тестов — get_branch_info -├── test_symbol_index_call_graph.py # 8 тестов — граф вызовов -├── ... (ещё 20 тестовых файлов) -``` - -**Всего: 853 теста.** - -Запуск: -```bash -pytest tests/ -m "not integration and not benchmark" -``` - ---- - -## Быстрая справка - -| Команда | Описание | -|---------|----------| -| `python -m src.main` | Запуск MCP-сервера (STDIO) | -| `pytest tests/` | Запуск всех тестов | -| `pytest tests/test_di_container.py -v` | Только тесты DI-контейнера | -| `python -c "from src.mcp.server import create_mcp_server; mcp = create_mcp_server()"` | Проверка загрузки сервера | - ---- - -## 12. Архитектурные инварианты - -Эти правила НЕ должны нарушаться ни одним новым PR. - -``` -1. Инструмент не обращается к Registry напрямую. -2. Инструмент не читает Bridge напрямую. -3. Инструмент работает только через RuntimeCoordinator. -4. RuntimeCoordinator не знает про Search / Indexer / Memory. -5. ProjectContext — immutable snapshot (не запускает операций). -6. Все системные файлы определяются только через SystemArtifacts. -7. Индексатор никогда не индексирует системные артефакты. -8. Любой путь проекта проходит через единый resolver (resolve_project_root). -9. Все Intel-инструменты используют ProjectContext (не низкоуровневые API). -10. Любой новый runtime-компонент обязан иметь одну ответственность. -11. Слой Core не имеет MCP-импортов. -12. Инструменты не создают зависимости — всё через DI. -13. server.py регистрирует — не содержит бизнес-логики. -``` +Память локальна и project-scoped. Это не общий chat log. + +## 11. Записи и безопасность -**Проверка при code review:** любой PR должен отвечать на вопрос -«Какой существующий слой расширяется?». Если ответ «никакой, я сделал -новый Manager/Services/Provider» — это повод остановиться. +Write tools (`rename`, `move`, `safe_delete`, `replace`, `insert_before/after`) +работают с символами, а не с сырым текстом. Pipeline: + +1. resolve symbol и references; +2. impact summary; +3. `ack_impact`, если файл load-bearing; +4. preview; +5. apply только с `apply=true`, затем refresh chunks/graph. + +Cross-file rename может использовать basedpyright LSP; при timeout — +fallback на SymbolIndex. `execute_script` выключен по умолчанию; при включении +использует AST validation, import wrapper и subprocess sandbox. + +## 12. Concurrency и multi-window + +- Один indexer/searcher на проект через registry. +- SQLite writes сериализуются lock'ом chunk store; reads безопасны между + потоками. +- Background indexing использует watchdog heartbeat и job state: агент + опрашивает статус, не блокируя редактор. +- Внешние процессы — `Popen` + `communicate(timeout=...)`, не + `capture_output` в daemon threads (Windows pipe deadlock). +- Reindex fast-fail возвращает понятную ошибку вместо зависания. + +## 13. Отказы и операционные проверки + +| Симптом | Первая проверка | Исправление | +|---|---|---| +| Graph tools пусты | nodes/edges в `graph.db` | `python scripts/rebuild_graph.py` | +| Поиск возвращает experiment docs | в индексе есть `experiments/**` | reindex; FileGuard их исключает | +| Docs всегда мимо | gold `docs/en`, ответ `docs/ru` | нормализовать по basename | +| Index stale | `codebase(action="index", path="status")` | `scripts/reindex_text_only.py` | +| Embedder missing | `debug_runtime_passport()` | text path работает; настроить provider | +| Tools не видны | `MSCODEBASE_MCP_TOOLS` | добавить имена или пустое значение | + +## 14. Тесты и проверка + +- `pytest tests/` — unit/integration suite; markers разделяют integration и + benchmark. +- `python scripts/smoke_e2e.py --project .` — реальные embed/rerank/search без + моков, когда providers доступны. +- `scripts/rebuild_graph.py` и `scripts/reindex_text_only.py` — штатные пути + починки graph/index drift. +- Search experiments — в `experiments/` с frozen inputs и raw runs; это источник + чисел качества в README. + +## 15. Связанные документы + +- [SEARCH_PIPELINE.md](SEARCH_PIPELINE.md) — retrieval, ranking, modes и limits. +- [ARCHITECTURE_DEEP.md](ARCHITECTURE_DEEP.md) — исторический deep dive и + опциональные детали. +- [GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md) — поведение без providers. +- [INSTALL.md](INSTALL.md) — установка и команды починки. +- [README.md](../../README.md) — текущий публичный контракт и качество. diff --git a/docs/ru/ARCHITECTURE_DEEP.md b/docs/ru/ARCHITECTURE_DEEP.md index 217917fd..9676a22c 100644 --- a/docs/ru/ARCHITECTURE_DEEP.md +++ b/docs/ru/ARCHITECTURE_DEEP.md @@ -4,9 +4,13 @@ > **Версия:** v3.5.0 | **Последнее обновление:** 2026-08-12 +> **Статус Phase 3 (2026-10-25):** дефолт — text-first (SQLite FTS5/BM25 + +> PropertyGraph); vector/LanceDB и `ask` на phi-4 опциональны. Это исторический +> deep-dive; актуальный контракт и границы — в [README.md](../../README.md). + ```mermaid flowchart TD - User[User / AI Agent] --> MCP[MCP Server\n61 tools] + User[User / AI Agent] --> MCP[MCP Server\n64 tools] MCP --> DI[DI Container\n15+ services] DI --> Search[Search Pipeline] DI --> Index[Indexing Pipeline] @@ -205,7 +209,7 @@ sequenceDiagram end DI-->>MCP: Контейнер готов - MCP->>MCP: Регистрация 61 инструментов + MCP->>MCP: Регистрация 64 инструментов MCP-->>Zed: Сервер готов (PID объявлен) Note over Zed,DB: Общий запуск: ~2-5s (асинхронная инициализация embedder) diff --git a/docs/ru/CONTRIBUTING.md b/docs/ru/CONTRIBUTING.md index 7a1fafa2..15b4ad2b 100644 --- a/docs/ru/CONTRIBUTING.md +++ b/docs/ru/CONTRIBUTING.md @@ -19,7 +19,7 @@ pip install -r requirements.txt pip install -e "." ``` -Требования: Python 3.10+, Windows (основная) или Linux (экспериментально). +Требования: Python 3.14+, Windows (основная) или Linux (экспериментально). --- diff --git a/docs/ru/INSTALL.md b/docs/ru/INSTALL.md index f20a5894..275013c0 100644 --- a/docs/ru/INSTALL.md +++ b/docs/ru/INSTALL.md @@ -6,9 +6,13 @@ [🇬🇧 English](../en/INSTALL.md) • [🇷🇺 Русский](INSTALL.md) • [🇨🇳 中文](../zh/INSTALL.md) -> **MSCodebase Intelligence** — MCP-сервер для семантического поиска кода в Zed IDE. +> **MSCodebase Intelligence** — MCP-сервер локального code intelligence в Zed IDE. > Работает как расширение Zed. Разработка в ``. +> **Phase 3 (2026-10-25):** дефолтная установка — text-first +> (SQLite FTS5/BM25 + PropertyGraph). Загрузка моделей и vector path +> опциональны; LanceDB — legacy/optional, а не дефолтный retrieval store. + --- ## 🔧 Системные требования diff --git a/docs/ru/README.md b/docs/ru/README.md index c2e7927d..9848f03c 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -2,491 +2,338 @@ MSCodeBase Баннер -[🇬🇧 English](../../README.md) • [🇷🇺 Русский](README.md) • [🇨🇳 中文](../zh/README.md) +[English](../../README.md) · [Русский](README.md) · [中文](../zh/README.md) # MSCodebase Intelligence -**ИИ-семантический поиск кода для Zed IDE — MCP-сервер глубокого анализа кода** +**Локальный code intelligence для Zed: лексический поиск, граф вызовов, impact analysis, память проекта и безопасные write-tools через MCP.** -[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) -[![Tests](https://img.shields.io/badge/tests-1371%20passed-brightgreen)](../../tests/) +[![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[Возможности](#-возможности) • [Быстрый старт](#-быстрый-старт) • [Инструменты](#mcp-инструменты-49-всего) • [Документация](#-карта-документации) • [Установка](INSTALL.md) • [Архитектура](ARCHITECTURE.md) • [Участие](../../CONTRIBUTING.md) • [Безопасность](../../SECURITY.md) - -*Последнее обновление: 2026-08-03* +*Обновлено: 2026-10-25. Английская страница — канон; этот перевод может отставать.* --- -## 🎯 Позиционирование - -**MSCodeBase Intelligence** — это MCP-сервер для **Zed IDE**, который предоставляет AI-ассистентам **глубокое понимание всей кодовой базы**: семантический поиск, граф вызовов, память проекта, диагностика. - -Это **не** LSP-сервер и не замена встроенному автодополнению редактора. Это слой «кодового интеллекта» поверх редактора: - -``` -┌─────────────────────────────────────────────────────┐ -│ Zed IDE │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ LSP (встроенное автодополнение, │ │ -│ │ подсказки в строке, диагностика) │ │ -│ └───────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ MSCodeBase (MCP-сервер) │ │ -│ │ · Семантический поиск по кодовой базе │ │ -│ │ · Граф вызовов и анализ влияния │ │ -│ │ · Память проекта (ADR, техдолг) │ │ -│ │ · Самодиагностика и самовосстановление │ │ -│ │ · 61 инструментов для AI-ассистента │ │ -│ └───────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ -``` - -### Что вы получаете - -| Возможность | MSCodeBase | Стандартный LSP (pyright/pylsp) | -|-------------|:----------:|:-------------------------------:| -| 🔍 **Семантический поиск** (BM25 + Vector + Reranker) | ✅ | ❌ | -| 🧠 **Граф вызовов + анализ влияния** | ✅ | ❌ | -| 🗃️ **Память проекта** (ADR, известные проблемы) | ✅ | ❌ | -| 🏥 **Самодиагностика + самовосстановление** | ✅ | ❌ | -| 🔎 **Кросс-репозиторный поиск** | ✅ | ❌ | -| 🤖 **Генерация ответов RAG** (mode=ask) | ✅ | ❌ | -| 🔬 **Прозрачность поиска** (per-stage score trace) | ✅ | ❌ | -| 🏛️ **Детектор дрейфа архитектуры** (chain/hub/circular) | ✅ | ❌ | -| ✅ **Верификация утверждений** (проверка фактов AI-агента) | ✅ | ❌ | -| ✏️ **Встроенное автодополнение** | ❌ | ✅ | -| 🏷️ **Подсказки в строке (inlay hints)** | ❌ | ✅ | - -### LSP: только для rename (гибридный режим) - -MSCodeBase **использует LSP только для `codebase(action="rename")`** — LSP-клиент (`src/core/lsp_client.py`) запускает **pyright-langserver** для точного rename между файлами, с автоматическим fallback на SymbolIndex (Tree-sitter) при таймауте. Вся остальная функциональность реализована через **61 MCP-инструментов**. - -Отдельный LSP-сервер (`src/lsp_main.py`) был экспериментальным и **не работает в Zed** — см. [LSP_WONTFIX.md](investigations/LSP_WONTFIX.md). - -### Платформы - -Спроектирован и протестирован на **Windows**. macOS и Linux должны работать, но официально не валидированы. - -### Языки - -| Язык | Парсинг | Граф вызовов | Data Flow (ASSIGNED_FROM) | -|---|---|---|---| -| **Python** | ✅ | ✅ | ✅ | -| **TypeScript** | ✅ | ✅ | ✅ | -| **TSX** | ✅ | ✅ | ✅ | -| **Rust** | ✅ | ✅ | ✅ | -| **Go** | ✅ | ✅ | ✅ | -| **JavaScript** | ✅ | ✅ | ✅ | -| **Java** | ✅ | ✅ | ✅ | -| **C#** | ✅ | ✅ | ✅ | -| **Ruby** | ✅ | ✅ | ✅ | -| **PHP** | ✅ | ✅ | ✅ | -| **Kotlin** | ✅ | ✅ | ✅ | -| **Swift** | ✅ | ✅ | ✅ | -| **C** | ✅ | ✅ | ✅ | -| **C++** | ✅ | ✅ | ✅ | -| **Scala** | ✅ | ✅ | ✅ | -| **Dart** | ✅ | ✅ | ✅ | -| **Shell/Bash** | ✅ | ❌ | ❌ | - -## ✨ Возможности - -| Возможность | Описание | -|-------------|----------| -| 🔍 **Унифицированный поиск** | `search_code(query, mode, intent_hint)` — единый инструмент: fast/quality/deep/context/ask/auto | -| 🧠 **Интеллектуальный слой** | 16 высокоуровневых инструментов `intel_*`: самодиагностика, топология, память, предсказание ошибок | -| 🗃️ **Память проекта** | ADR, известные проблемы, технический долг — автоматически сохраняется между сессиями | -| 🌐 **Кросс-репозиторный поиск** | Поиск по нескольким проектам с синтаксисом `@mention` | -| 🌳 **Граф вызовов** | Полный граф вызовов: определение + вызывающие + вызываемые + анализ влияния | -| 🏗 **Структурный поиск** | 13 AST-паттернов (class_inheritance, async_function, decorator и др.) | -| 🔎 **Контекстный поиск** | Поиск похожего кода — вставьте фрагмент, получите семантические дубликаты | -| 🪣 **Мульти-бакетный RAG** | Бакеты кода/документации, мягкое взвешивание, intent_hint (code/docs/auto) | -| 🤖 **mode=ask** | Генерация ответов RAG через phi-4 (профиль server) | -| 💾 **LanceDB v2** | Векторная БД с изоляцией по проектам (инкрементальный BM25-реиндекс) | -| 🛡 **Ограничение запросов** | DebounceBatch + CircuitBreaker — защита от VFS-циклов | -| 🏥 **Самодиагностика** | `get_health_report` + `index_health` — полная проверка и восстановление | -| 🧪 **Чистая архитектура** | DI-контейнер (18 сервисов), 62 инструментов (29 core + 16 intel + 13 inline + 4 dev), 1371 тестов | -| 🔗 **Граф потока данных** | Рёбра `ASSIGNED_FROM` отслеживают присваивания. Unified Walker + Conditional Flow (if/for/while/try). 29 типов рёбер в PropertyGraph. | -| 🪟 **Мульти-оконность** | `ProjectIndexerRegistry` — изолированный Indexer на проект, LRU 5, ResourceMonitor throttle | -| ✏️ **Write Tools** | `codebase(action=...)` — единый хаб модификации кода: rename/move/delete/replace/insert с preview/apply + `@modification_guard` | -| ⚡ **Meta-Patching** | LanceDB `move_chunks_metadata` — file_path rename без пере-эмбеддинга (50ms против 5s) | -| ⚙️ **SYSTEM_PROFILE** | `light` (синхронный) / `server` (асинхронный с phi-4) | - ---- - -## 🚀 Быстрый старт - -Установите расширение `mscodebase-intelligence` в Zed, затем: +## Что это + +MSCodebase Intelligence — **локальный MCP-сервер**, который даёт AI-ассистенту +в Zed обоснованное представление о кодовой базе. Это не чат-модель и не замена +LSP редактора. Его задачи: + +- Где определён символ, кто его вызывает и что сломается при изменении? +- Какие файлы относятся к вопросу — в коде, тестах или документации? +- Что проект уже решил и какие инциденты всё ещё открыты? +- Можно ли безопасно preview/apply rename, move, replace? + +Текущий дефолт — **text-first retrieval**: SQLite FTS5/BM25 плюс индекс +Tree-sitter/PropertyGraph. Провайдеры эмбеддингов и реранкеров существуют, но +они опциональны и не нужны для дефолтного пути. Это осознанный выбор: сервер +остаётся полезным offline, latency предсказуема, а векторная БД не становится +единственной точкой отказа. + +## Что работает сегодня + +| Область | Что делает | Примечание | +|---|---|---| +| Поиск по коду | BM25/FTS5 по чанкам; exact-symbol и path-сигналы; опциональное расширение по графу | `search_code` | +| Поиск по документации | Отдельный FTS-канал `docs/**`, включается автоматически при сильном BM25-разрыве | Без LLM-роутера | +| Символы | Определение, callers/callees, references, impact, structural search | `get_symbol_info`, `impact_analysis`, `structural_search` | +| Граф | PropertyGraph файлов/символов: call, import, assignment, decorator, override | Пересборка: `scripts/rebuild_graph.py` | +| Память проекта | ADR, инциденты, known issues, retract/supersede | `intel_*` | +| Безопасные записи | Rename, move, delete, replace, insert с preview/impact | `codebase(action=...)` | +| Диагностика | Runtime passport, health, index status, logs, jobs | `debug_runtime_passport`, `get_health_report` | +| LSP rename | Cross-file rename через basedpyright + fallback SymbolIndex | Только rename | + +## Качество поиска: измеренное, с границами + +Числа без методики бесполезны. Ниже — внутренняя **панель из 48 запросов** +(33 реальных вопроса по проекту + 15 вопросов из docstring), чистый индекс без +`experiments/**`, граф присутствует. Это не универсальный benchmark и не +описание всех языков/репозиториев. + +| Путь | hit@1 | hit@5 | Что означает | +|---|---:|---:|---| +| Код, quality | 0.825 | 0.900 | 33/40 code-вопросов вернули нужный файл первым | +| Docs, exact path | 0.000 | 0.125 | Gold указывает `docs/en/X`, поиск часто отдаёт `docs/ru/X` | +| Docs, same basename | 0.750 | 0.750 | Перевод считается тем же документом | +| Общий quality | 0.688 | 0.750 | Код и docs ровно так, как размечена панель | +| Общий auto docs route | 0.792 | 0.875 | BM25-margin router + docs-канал; переводы нормализованы | +| Fast | 0.542 | 0.729 | Ниже качество, ниже latency; для exact/simple lookup | + +Чего эти числа **не** означают: + +- Это **не** 95% точности. Ни одна статическая схема на этой панели не дала + такого результата. Two-channel repo map (code PageRank + docs BM25) дал около + 40% / 62% / 73% покрытия gold-файлов при 1k / 2k / 4k токенов и идеальном + routing; PageRank отдельно — около 33% / 55% / 68% на code-запросах. Это + инструмент выбора контекста, не замена поиску. +- Это **не** значит, что любой локальный реранкер улучшает результат. Несколько + моделей были измерены и отклонены: они снижали hit@1 или добавляли + неприемлемую latency на этой панели. +- Это **не** значит, что docs-канал сломан. Он часто отдаёт правильный документ + на другом языке (`docs/ru/X` вместо `docs/en/X`). Это вопрос канонизации gold, + а не retrieval. + +Панель, сырые прогоны, гипотезы и summary лежат в `experiments/`. Это +воспроизводимый lab notebook, а не маркетинговый текст. + +## Как работает поиск + +Дефолтный путь детерминированный: + +1. **Parse/index** — файлы → чанки + PropertyGraph. +2. **Retrieve** — SQLite FTS5/BM25, symbol/path-сигналы, опционально соседи графа. +3. **Rank/clean** — опциональный on-demand rerank, exact-name boost и + дедупликация символов. RRF/MMR/dense fusion относятся к опциональному + LanceDB/vector path, а не к каждому дефолтному запросу. +4. **Docs routing** — если лучший docs-score превышает лучший code-score на + настраиваемый margin (`MSCODEBASE_DOCS_MARGIN`, default `4.0`). +5. **Return** — ранжированные чанки/файлы с metadata. `intent="docs"` или + `intent="code"` переопределяют автоматический выбор. + +В routing нет LLM. Это один дополнительный FTS-запрос и сравнение scores: +локально, объяснимо, дешево. + +Опциональные слои: + +- Embedding-провайдеры (llama.cpp GGUF, ONNX, LM Studio/Ollama) для vector candidates. +- On-demand rerank при настроенном провайдере. +- Graph expansion и PageRank-подобный repo ranking. + +При `MSCODEBASE_DENSE_OFF=true` (текущий default) сервер не требует embedding +server для code/docs запросов. + +## Быстрый старт + +Требования: Windows — валидированная платформа; Python 3.14+; Zed с расширением +`mscodebase-intelligence`. ```bash cd python install.py - -# Перезапустите Zed (File → Quit → reopen) -# Проверьте: intel_get_runtime_status() -``` - -**install.py выполняет:** -1. Копирует 39+ файлов исходников в директорию расширения -2. Устанавливает Python-зависимости -3. Скачивает llama-server.exe (reranker) + модель multilingual-e5-small (ONNX, CPU) -4. Настраивает MCP в settings.json Zed - -См. также: [AI_INSTALLATION_PROMPT.md](../../AI_INSTALLATION_PROMPT.md), [INSTALL.md](INSTALL.md) - -### Провайдеры - -MCP использует multilingual-e5-small (ONNX, CPU, in-process) для эмбеддингов и llama-server для реранкера: - -``` -multilingual-e5-small ONNX (CPU, in-process) → llama-server reranker - ~0.5 GB RAM ~1.0 GB RAM - ~52 ch/s 1 процесс +# code-only sync без вопросов: +python install.py --sync +# CI-режим без вопросов: +python install.py --yes ``` -Бенчмарки: [../../docs/research/2026-07-10-final-benchmark.md](../../docs/research/2026-07-10-final-benchmark.md) +`install.py` копирует сервер в extension directory Zed, ставит зависимости, +опционально готовит модели и пишет MCP-конфиг. После завершения перезапустите Zed. ---- - -## 📚 Карта документации - -| Документ | Описание | Аудитория | Языки | -|----------|----------|-----------|-------| -| **[INSTALL.md](INSTALL.md)** | Установка, настройка, удаление | Пользователи | 🇬🇧 🇷🇺 🇨🇳 | -| **[ARCHITECTURE.md](ARCHITECTURE.md)** | Чистая архитектура, слои, DI | Разработчики | 🇬🇧 🇷🇺 🇨🇳 | -| **[ARCHITECTURE_DEEP.md](ARCHITECTURE_DEEP.md)** | Глубокая архитектура: pipeline, lifecycle, сравнение | Архитекторы | 🇬🇧 🇷🇺 🇨🇳 | -| **[SEARCH_PIPELINE.md](SEARCH_PIPELINE.md)** | Пайплайн поиска: BM25 → RRF → Reranker | Разработчики | 🇬🇧 | -| **[GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md)** | 5 уровней плавной деградации (llama.cpp → ONNX → BM25) | DevOps | 🇬🇧 | -| **[ARCHITECTURE_LAYERS.md](ARCHITECTURE_LAYERS.md)** | 10 слоев рантайма | Архитекторы | 🇬🇧 🇷🇺 🇨🇳 | -| **[FAQ.md](FAQ.md)** | Часто задаваемые вопросы | Все | 🇬🇧 🇷🇺 🇨🇳 | -| **[TELEMETRY.md](TELEMETRY.md)** | Метрики, ETA, сбор данных | DevOps | 🇬🇧 🇷🇺 🇨🇳 | -| **[investigations/ONNX_SESSION_REPORT.md](../en/investigations/ONNX_SESSION_REPORT.md)** | Полная миграция ONNX, 7 исправлений, бенчмарки | Поддержка | 🇬🇧 | -| **[investigations/LSP_WONTFIX.md](investigations/LSP_WONTFIX.md)** | Исследование LSP на Windows (WONTFIX) | Поддержка | 🇬🇧 🇨🇳 | -| **[ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md)** | Особенности Windows, Restricted Mode | Пользователи Windows | 🇬🇧 🇷🇺 🇨🇳 | -| **[CHANGELOG.md](CHANGELOG.md)** | История версий | Все | 🇬🇧 🇷🇺 🇨🇳 | -| **[CONTRIBUTING.md](CONTRIBUTING.md)** | Как внести вклад, PR | Контрибьюторы | 🇬🇧 🇷🇺 🇨🇳 | -| **[SECURITY.md](SECURITY.md)** | Политика безопасности, уязвимости | Безопасность | 🇬🇧 🇷🇺 🇨🇳 | -| **[../../AGENTS.md](../../AGENTS.md)** | Системные правила AI-агента | AI-агент | 🇬🇧 | -| **[../../SECURITY.md](../../SECURITY.md)** | Политика безопасности, сообщение об уязвимостях | Безопасность | 🇬🇧 | -| **[../../CODE_OF_CONDUCT.md](../../CODE_OF_CONDUCT.md)** | Стандарты сообщества | Контрибьюторы | 🇬🇧 | - -| **[../../KNOWN_ISSUES.md](../../KNOWN_ISSUES.md)** | Известные проблемы и реестр техдолга | Все | 🇬🇧 | - -Все документы перекрёстно ссылаются друг на друга. Доступны на 3 языках: English, Русский, 中文. - ---- +Проверка из agent panel: -## MCP Инструменты (64 всего) - -### Основной поиск - -| Инструмент | Когда использовать | -|------------|-------------------| -| `search_code(query, mode, filter_layer, intent_hint)` | **Главный инструмент поиска.** `mode="auto"` / `"fast"` / `"quality"` / `"deep"` / `"context"` / `"ask"`. `intent_hint="code"` / `"docs"` / `"auto"` — мягкое взвешивание бакетов. `filter_layer="core"` — поиск в конкретном архитектурном слое | -| `structural_search(pattern)` | AST-поиск: `class_inheritance`, `async_function`, `function_with_decorator` и другие | -| `cross_repo_search(query @repo)` | Поиск по нескольким проектам (моно-репозиторий) | -| `cross_project_deps(action)` | Граф зависимостей между проектами: `graph` / `deps` / `cycles` / `impact` | -| `get_symbol_info(query)` | Граф вызовов: вызывающие, вызываемые, затрагиваемые файлы | -| `execute_script(code, timeout, args)` | **Песочница Python-выполнения (3 слоя).** AST-валидация + runtime-обёртка `__import__` + изоляция subprocess. Аудит-лог. Возвращает `{stdout, stderr, exit_code, duration_ms, truncated, timed_out}` | -| `impact_analysis(symbol)` | Анализ влияния изменений символа (оценка риска, глубина) | - -### Управление индексом (через `codebase(action="index", ...)`) - -| Действие | Когда использовать | -|------|-------------| -| `codebase(action="index", path="status")` | Статус индекса: чанки, файлы, символы (`get_index_status`) | -| `codebase(action="index", path="progress")` | Прогресс индексации (фаза, проценты) | -| `codebase(action="index", path="project_dir", project_root=...)` | Запустить полную индексацию проекта (`index_project_dir`) | -| `codebase(action="index", path="timeline")` | История индексации по датам | -| `codebase(action="index", path="health")` | Диагностика индекса и самовосстановление (`index_health`) | -| `notify_change(file_path)` | Принудительное обновление индекса для файла (через DebounceBatch) — inline-тул | -| `generate_chunk_summaries(root)` | LLM-генерированные описания для чанков кода | -| `scan_changes(project_root)` | Архитектурный diff — анализ изменений с последнего baseline | - -### Система и диагностика - -| Инструмент | Когда использовать | -|------------|-------------------| -| `get_health_report()` | **Полная самодиагностика:** индекс, эмбеддер, логи, синхронизация | -| `get_logs(project_root)` | Последние ошибки и предупреждения из логов проекта | -| `read_live_file(path)` | Чтение файла из памяти LSP (включая несохранённые изменения) | - -### Аналитика - -| Инструмент | Когда использовать | -|------------|-------------------| -| `get_hotspots(project_root)` | Горячие точки — файлы с высоким уровнем багов | -| `get_repo_rank(project_root, top_k)` | Ранжирование важностей символов (PageRank на графе вызовов) | -| `get_bug_correlation(project_root)` | Анализ корреляции багов и изменений | -| `get_repo_map(project_root)` | Карта проекта: дерево файлов + ключевые символы | -| `graph_query(action="related", target=path)` | Файлы, связанные через совместные изменения / корреляцию багов (через `related` action) | -| `graph_query(action, target)` | Запросы к графу: `impact` / `feature` / `deps` / `tests` / `cypher` / `flow` / `drift` / `verify` | -| `find_similar_bugs(error)` | Поиск похожих багов из истории по тексту ошибки | - -### Git и история (через `codebase(action="git", ...)`) - -| Действие | Когда использовать | -|------|-------------| -| `codebase(action="git", path="log", ...)` | Семантическая история коммитов (`get_commit_history`) | -| `codebase(action="git", path="history", ...)` | История изменений конкретного файла | -| `codebase(action="git", path="branch")` | Информация о ветке + статус индекса (`get_branch_info`) | - -### Жизненный цикл и верификация - -| Инструмент | Когда использовать | -|------------|-------------------| -| `submit_background_task(type, root)` | Запуск долгих задач: `bug_correlation` / `build_knowledge_graph` / `full_analysis` | -| `get_task_status(task_id)` | Статус фоновой задачи | -| `verify_action(action_type)` | Верификация: `file_write` / `git_commit` / `git_push` / `index_sync` | - -### Write Tools — `codebase(action=...)` - -Единый хаб модификации кода. Все операции через один инструмент с параметром `action`: - -| action | Описание | -|--------|----------| -| `rename` | Переименование символа во всех файлах (preview/apply, проверка коллизий) | -| `move` | Перемещение символа в другой файл (preview/apply, обновление импортов) | -| `delete` | Безопасное удаление с проверкой ссылок (force mode) | -| `replace` | Замена тела функции/класса (preview/apply) | -| `insert_before` | Вставка кода перед anchor-символом (preview/apply) | -| `insert_after` | Вставка кода после тела anchor (preview/apply) | -| `ack_impact` | Подтверждение влияния для modification guard | - -### Интеллектуальный слой (intel_*) — 16 высокоуровневых инструментов - -| Инструмент | Назначение | -|------------|------------| -| `intel_get_runtime_status()` | Агрегированный статус здоровья: эмбеддер, индекс, использование ресурсов | -| `intel_trigger_reindex()` | Реиндексация без ожидания (не блокирует Zed) | -| `intel_get_job_status(job_id)` | Прогресс фоновой задачи | -| `intel_code_topology(symbol)` | Граф вызовов + топология модулей (< 2 сек) | -| `intel_get_project_memory()` | Карта памяти проекта: ADR, known_issues, tech_debt | -| `intel_log_incident(...)` | Запись инцидента в историю проекта | -| `intel_analyze_incident(error)` | Поиск похожих инцидентов + готовые решения | -| `intel_add_memory_node(section, data)` | Добавление записи в память проекта | -| `intel_get_hotspots()` | Топ-5 файлов с наибольшей баг-нагрузкой | -| `intel_predict_root_cause(error)` | Предсказание первопричины по логам + истории | -| `intel_get_telemetry(days)` | Поинструментальная телеметрия, использование ресурсов, статистика LLM | -| `intel_auto_collect_adrs(max_commits)` | Автогенерация ADR из истории коммитов | -| `intel_reset_index()` | Удалить и пересобрать индекс с нуля | -| `intel_retract_memory_node(node_id, reason)` | Отозвать узел памяти (ACTIVE/VERIFIED → REFUTED, причина обязательна) | -| `intel_restore_memory_node(node_id, reason)` | Восстановить узел из REFUTED (ручной возврат, ADR-0002/0003) | -| `intel_supersede_memory_node(node_id, reason, new_node_id)` | Пометить узел как SUPERSEDED — заменён более свежим фактом | - -> `intel_tool_health()`, `intel_explain_project_state()`, `intel_get_project_context()` — см. Диагностические инструменты ниже. - -### Dev Tools (4) - -| Инструмент | Назначение | -|------------|------------| -| `generate_docs(project_root)` | Генерация Markdown-документации из PropertyGraph (DEPRECATED — используйте auto_update_docs) | -| `bump_version(project_root, part, dry_run)` | Бамп версии проекта + обновление CHANGELOG | -| `auto_update_docs(project_root, action)` | Автообновление документации: update/check | -| `install_git_hooks(project_root, action)` | Установка pre-commit хуков: install/uninstall/status | - -### Диагностические инструменты (7) - -| Инструмент | Назначение | -|------------|------------| -| `debug_runtime_passport()` | Паспорт процесса: RUN_ID, PID, информация о сборке | -| `get_runtime_counters()` | Счётчики рантайма: вызовы, блокировки, предупреждения | -| `intel_execution_timeline(limit)` | Лента последних действий с длительностью | -| `intel_get_project_context(root)` | Единый снэпшот: состояние, индекс, здоровье, память | -| `intel_explain_project_state(root)` | Человекочитаемый диагноз состояния проекта | -| `intel_tool_health()` | Процент успеха инструментов, задержки, уверенность | -| `refresh_db_connection()` | Сброс handle БД и переподключение | - ---- - -## 🏗️ Архитектура - -### Чистая архитектура с DI-контейнером - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ MCP Server (~1000 строк) │ -│ server.py + server_tools.py + server_factory.py — регистрация и маршрутизация │ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ DI Container (18 сервисов) │ │ -│ │ src/core/di_container.py — ServiceCollection │ │ -│ │ │ │ -│ │ ┌──────────┐ ┌────────────┐ ┌──────────────────────┐ │ │ -│ │ │ Indexer │ │ Searcher │ │ DebounceBatch │ │ │ -│ │ │ Embedder │ │ SymbolIdx │ │ CircuitBreaker │ │ │ -│ │ │ Parser │ │ FileGuard │ │ RateLimiter │ │ │ -│ │ └──────────┘ └────────────┘ └──────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────┴────────────┐ │ -│ ▼ ▼ │ -│ ┌────────────────────┐ ┌────────────────────────────────────┐ │ -│ │ 28 Классов │ │ 16 intel_* + 13 inline │ │ -│ │ │ src/mcp/tools/*.py │ │ src/core/intelligence_layer.py │ │ -│ │ │ Один класс на │ │ decorator error_boundary │ -│ │ │ инструмент │ │ JSON status/message/detail │ -│ │ │ Constructor Inj. │ │ asyncio.wait_for(timeout) │ │ -│ │ │ Constructor Inj. │ │ │ │ -│ │ └────────────────────┘ └────────────────────────────────────┘ │ -│ └──────────────────────────────────────────────────────────────────┘ -│ │ -│ ▼ -│ ┌─────────────────┐ ┌───────────────────┐ -│ │ RemoteEmbedder │ │ LanceDB v2 │ -│ │ (ONNX multilingual-e5-small, │ │ (Vector DB) │ -│ │ CPU, in-process)│ │ BM25 + Vector │ -│ └─────────────────┘ └───────────────────┘ +```text +debug_runtime_passport() +intel_get_runtime_status() +codebase(action="index", path="status") ``` ---- +Если граф пустой, а чанки есть — пересоберите только граф (без векторов): -## ⚡ Производительность - -| Режим | Задержка | Лучше всего для | -|:------|:---------|:----------------| -| `search_code(query, mode="fast")` | ~80-500ms | Простой ключевой слова / точное имя | -| `search_code(query, mode="quality")` | ~250-2000ms | Семантический поиск с реранкером | -| `search_code(query, mode="deep")` | ~2-5s | Сложное исследование по модулям | -| `search_code(query, mode="context")` | ~200-800ms | Поиск похожего кода по фрагменту | -| `get_symbol_info(query)` | ~200-1500ms | Определение символа + граф вызовов | -| `impact_analysis(symbol)` | ~1-5s | Анализ влияния изменений | - -### Переменные окружения - -| Переменная | Значение по умолчанию | Описание | -|------------|----------------------|----------| -| `LM_STUDIO_URL` | `http://localhost:1234/v1` | API-ендпоинт LM Studio | -| `LM_STUDIO_PORT` | `1234` | Порт LM Studio | -| `OLLAMA_URL` | `http://localhost:11434` | API-ендпоинт Ollama | -| `LOG_LEVEL` | `INFO` | Уровень детализации логирования | -| `ZED_WINDOWS_QUIRKS.md` | *(см. файл)* | Инструкции для Windows | - ---- - -## 🔧 Устранение неполадок - -### MCP-сервер не отвечает - -**Симптомы:** таймаут инструментов, нет ответа. - -**Что проверить:** -1. **File → Quit** → откройте проект заново -2. Запустите `python install.py` для перенастройки -3. Проверьте логи: `%LOCALAPPDATA%\mscodebase\logs\` (data_root) - -### Индекс пуст (0 чанков) - -Запустите в панели агента: -``` -intel_trigger_reindex() +```bash +python scripts/rebuild_graph.py ``` -Затем проверьте: `get_index_status()` - -### Проблемы с подключением LM Studio +Если индекс устарел/пуст — пересоберите текст и граф: ```bash -# Проверьте, отвечает ли сервер: -python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:1234/v1/health').read())" +python scripts/reindex_text_only.py --project . ``` -Ожидается: `{"status":"ok"}`. - ---- - -## 📁 Структура проекта - -``` -mscodebase-intelligence/ -├── src/ -│ ├── main.py # Точка входа MCP-сервера (~200 строк) -│ ├── lsp_main.py # LSP-сервер (на DI, для индексации при didSave) -│ ├── mcp/ -│ │ ├── server.py # Создание MCP-сервера (~597 строк) -│ │ ├── server_factory.py # DI setup + жизненный цикл сервера (~478 строк) -│ │ ├── server_tools.py # Регистрация инструментов + 13 inline (~607 строк) -│ │ └── tools/ # 13 модулей + base-класс -│ │ ├── codebase_tool.py # codebase(action=...) hub + execute_script -│ │ ├── search_tools.py # search_code, get_symbol_info, impact_analysis -│ │ ├── indexing_tools.py # notify_change, index_project_dir, index_health -│ │ ├── git_tools.py # get_branch_info, get_commit_history, get_file_history -│ │ ├── system_tools.py # get_index_status, get_health_report, read_live_file, get_logs -│ │ ├── analysis_tools.py # structural_search, get_repo_map, get_repo_rank, scan_changes -│ │ ├── graph_tools.py # cross_repo_search, cross_project_deps, graph_query -│ │ ├── investigation_tools.py # get_bug_correlation, get_hotspots, find_similar_bugs -│ │ ├── lifecycle_tools.py # submit_background_task, get_task_status, verify_action -│ │ ├── meta_tools.py # IndexTool, GitTool, SystemTool (spoke для codebase hub) -│ │ └── write_tools.py # WriteTool (rename, move, delete, replace, insert) -│ ├── core/ -│ │ ├── di_container.py # ★ DI-контейнер (18 сервисов, ServiceCollection) -│ │ ├── error_handler.py # ★ error_boundary + ToolError -│ │ ├── rate_limiter.py # ★ SlidingWindowRateLimiter + DebounceBatch + CircuitBreaker -│ │ ├── indexer.py # Векторное хранилище LanceDB -│ │ ├── searcher.py # Гибридный поиск (BM25 + Dense + RRF) -│ │ ├── symbol_index.py # Граф вызовов (BFS, анализ влияния) -│ │ ├── intelligence_layer.py # Инструменты intel_* (13 высокоуровневых) -│ │ ├── llama_runner.py # ★ Менеджер жизненного цикла llama-server (reranker) -│ ├── remote_embedder.py # ONNX/OpenVINO multilingual-e5-small (in-process) + LM Studio / Ollama fallback -│ │ ├── reranker.py # Мульти-провайдерный реранкер (HTTP к провайдерам) -│ │ ├── parser.py # Tree-sitter AST -│ │ ├── health_report.py # Движок самодиагностики -│ │ └── ... -│ └── utils/ -│ ├── paths.py # SafePathManager, to_win_long_path -│ └── zed_config.py # Автонастройка Zed -├── docs/ -│ ├── en/ # Документация на английском -│ ├── ru/ # Документация на русском -│ └── zh/ # Документация на китайском -├── tests/ # 853 теста (pytest) -├── .agents/skills/ # Навыки для AI-агента -├── install.py # Установщик -└── README.md +Затем перезапустите MCP/Zed, чтобы новый процесс открыл пересобранные stores. + +## Инструменты + +Сервер регистрирует **64 инструмента** в четырёх группах; `execute_script` +добавляет один опциональный инструмент при +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. Default allowlist показывает **49**; +скрытые инструменты включаются через `MSCODEBASE_MCP_TOOLS` (пустая строка — +показать все). Точный набор виден в startup-логе MCP и в +`intel_get_runtime_status`. + +| Группа | Примеры | Назначение | +|---|---|---| +| Поиск и анализ | `search_code`, `plan_search`, `get_symbol_info`, `impact_analysis`, `structural_search`, `graph_query` | Код, символы, связи, impact | +| Индекс и система | `codebase(action="index"...)`, `notify_change`, `read_live_file`, `get_health_report`, `get_logs` | Свежесть и диагностика индекса | +| Intelligence | `intel_get_runtime_status`, `intel_get_project_memory`, `intel_log_incident`, `intel_analyze_incident`, `intel_predict_root_cause` | Память, инциденты, runtime | +| Записи и lifecycle | `codebase(action="rename"...)`, `move`, `safe_delete`, `replace`, `insert_before/after`, `ack_impact` | Preview/apply безопасных изменений | +| Dev | `bump_version`, `auto_update_docs`, `install_git_hooks`, `generate_docs` | Обслуживание репозитория | + +`codebase` — hub: `action` выбирает index, git или write. Смотрите tool schema +в MCP-клиенте, а не рукописный список, который может устареть. + +## Архитектура + +```text +Zed + │ + ├── built-in LSP (autocomplete, diagnostics) + │ + └── MCP client + │ + ▼ + MSCodebase MCP server + ├── Search SQLite FTS5/BM25, symbol/path signals, optional graph + ├── Graph PropertyGraph: calls, imports, assignments, decorators, overrides + ├── Index parser → chunks → FTS5 + graph; freshness checks + ├── Memory ADRs, incidents, known issues, retract/supersede + ├── Writes rename/move/delete/replace/insert with impact guards + ├── Diagnostics runtime passport, health, jobs, logs, telemetry + └── Optional embedding providers, reranker, LSP rename ``` ---- - -## 🛠️ Разработка - -См. [CONTRIBUTING.md](CONTRIBUTING.md) для: -- Как добавлять новые MCP-инструменты -- Структура тестов и CI-пайплайн -- Соглашения по сообщениям коммитов +Канонические данные лежат вне репозитория: + +- `%LOCALAPPDATA%/mscodebase/projects//` — per-project graph, memory, jobs. +- `%LOCALAPPDATA%/mscodebase/logs/` — логи сервера. +- `/.mscodebase/chunks.db` — text chunk store для Phase 3. + +LanceDB остаётся частью legacy/optional vector path; для дефолтного text-first +режима она не требуется. + +## Конфигурация + +Все настройки — environment variables с defaults в `src/config/settings.py`. +Главные для поиска: + +| Переменная | Default | Эффект | +|---|---|---| +| `MSCODEBASE_DENSE_OFF` | `true` | Пропустить dense/vector retrieval; FTS5, symbols, graph | +| `MSCODEBASE_SQLITE_CHUNKS` | `true` | Использовать SQLite/FTS5 chunk store | +| `MSCODEBASE_DEMOTE_NON_CODE` | `false` | Понижать tests/docs/experiments в fused pool | +| `MSCODEBASE_DEMOTE_INTENT_AWARE` | `false` | Не понижать docs при docs-intent | +| `MSCODEBASE_DOCS_MARGIN` | `4.0` | Порог auto docs routing (docs − code score) | +| `MSCODEBASE_ONDEMAND_RERANK` | `true` | Опциональный live rerank; нужен провайдер | +| `MSCODEBASE_P3_ANCHOR` | `false` | Module-head anchors; без reranker дают latency | +| `MSCODEBASE_MCP_TOOLS` | default set | Allowlist инструментов; пусто = все | + +`experiments/**` намеренно исключён из индекса. Там frozen-панели и run +artifacts; их индексация раньше заставляла BM25 возвращать саму панель вместо +кода. Файлы при этом остаются в git. + +## Поддерживаемые языки + +Python — основной, полностью проверенный язык. Tree-sitter parsing покрывает +JavaScript, TypeScript/TSX, Rust, Go, Java, C#, Ruby, PHP, Kotlin, Swift, C, +C++, Scala и Dart. SQL, YAML, TOML, HTML, CSS и HCL/Terraform индексируются как +контекст, без такого же покрытия call graph. Глубина графа и data-flow зависит +от grammar; не все языки дают одинаковые типы рёбер. + +## Ограничения и известные проблемы + +Это осознанные, документированные границы, а не скрытое поведение: + +- **Docs gold vs язык:** docs-канал может вернуть `docs/ru/X.md`, когда gold — + `docs/en/X.md`. Документ правильный, exact-path метрика — нет. При оценке + переводов нормализуйте по basename. +- **Auto docs routing эвристичен:** margin настраивается и подобран на панели 48 + запросов. Code-запрос с сильными docs-словами может уйти в docs; + `intent="code"` — escape hatch. +- **Graph rebuild отдельно от text reindex:** при `MSCODEBASE_DENSE_OFF=true` + полный vector reindex намеренно блокируется. Для графа — + `scripts/rebuild_graph.py`, для чанков — `scripts/reindex_text_only.py`. +- **Нет заявки на 95%:** статический retrieval и протестированные локальные + реранкеры не дали этого на внутренней панели. LLM planner/agent loop — другой + эксперимент и не дефолтный путь. +- **Fast mode — не quality mode:** быстрее, но заметно менее точен на смешанных + natural-language вопросах. Для exact names и известных путей. +- **Видимые tools — allowlist:** «64 tools» в исходниках не значит, что все 64 + видны конкретному клиенту; проверяйте `MSCODEBASE_MCP_TOOLS`. + +## Как сообщать о баге + +Полезный отчёт — маленький эксперимент, а не только число. Укажите: + +1. Точный tool call и параметры (`query`, `mode`, `intent`, `limit`). +2. `debug_runtime_passport()` и `codebase(action="index", path="status")`. +3. Пересобирался ли граф (`scripts/rebuild_graph.py`) и были ли включены + `MSCODEBASE_DENSE_OFF` / `MSCODEBASE_ONDEMAND_RERANK`. +4. Ожидаемый файл и фактически возвращённые файлы; exact-path или same-basename + (переводы считаются тем же документом). +5. Минимальный запрос для воспроизведения и хвост лога из + `%LOCALAPPDATA%/mscodebase/logs/`. + +Это позволяет отделить четыре разные проблемы, которые выглядят одинаково: +плохой ranking, отсутствующий candidate, stale/empty index и gold-label mismatch. + +## Структура проекта + +```text +src/ + main.py MCP entry point + mcp/ + server.py server creation + server_factory.py DI and lifecycle + server_tools.py tool registration and inline tools + tools/ search, index, graph, memory, write, lifecycle tools + core/ + search/ engine, FTS5/BM25, scoring, query plan, graph adapter + indexing/ parser, chunk store, index pipeline, file guard + intelligence/ project memory, incidents, health, jobs + graph.py PropertyGraph + di_container.py service collection + config/settings.py environment-backed settings + providers/ optional embedders and rerankers +experiments/ frozen panels, run artifacts, summaries +scripts/ install/reindex/graph/smoke utilities +tests/ pytest suite +docs/ architecture, installation, operations, research +``` -### Быстрый старт для разработчиков +## Разработка ```bash -# Настройка python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt -# Запуск MCP-сервера напрямую (тест) -python -m src.main +# Focused search tests +pytest tests/test_search_docs.py tests/test_query_plan.py \ + tests/test_experiment_output_guard.py tests/test_searcher.py -q -# Запуск тестов +# Full suite (integration/benchmark tests are selected by markers) pytest tests/ -m "not integration and not benchmark" + +# Live smoke: real services, no mocks +python scripts/smoke_e2e.py --project . ``` ---- +Репозиторий использует Conventional Commits, хранит входы экспериментов в +`experiments/**/frozen/` и ведёт результаты в `EXPERIMENTS_LOG.md`, +`AGENT_DIARY.md` и `KNOWN_ISSUES.md`. -## 📄 Лицензия +## Карта документации -Лицензия MIT — подробнее в [LICENSE](../../LICENSE). +| Документ | О чём | +|---|---| +| [docs/en/INSTALL.md](../en/INSTALL.md) | Установка, uninstall, platform notes | +| [docs/en/ARCHITECTURE.md](../en/ARCHITECTURE.md) | Слои, DI, границы модулей | +| [docs/en/ARCHITECTURE_DEEP.md](../en/ARCHITECTURE_DEEP.md) | Pipeline и lifecycle подробно | +| [docs/en/SEARCH_PIPELINE.md](../en/SEARCH_PIPELINE.md) | Retrieval, fusion, scoring, rerank | +| [docs/en/GRACEFUL_DEGRADATION.md](../en/GRACEFUL_DEGRADATION.md) | Поведение без провайдеров | +| [docs/en/ZED_WINDOWS_QUIRKS.md](../en/ZED_WINDOWS_QUIRKS.md) | Windows и Zed | +| [docs/en/CHANGELOG.md](../en/CHANGELOG.md) | История версий | +| [AGENTS.md](../../AGENTS.md) | Правила для AI-агентов | +| [KNOWN_ISSUES.md](../../KNOWN_ISSUES.md) | Открытые проблемы и долг | +| [EXPERIMENTS_LOG.md](../../EXPERIMENTS_LOG.md) | Гипотезы и сырые результаты | ---- +## Research notes + +- [PageRank vs RAG on a real codebase](https://dev.to/mansio/i-measured-pagerank-token-savings-on-a-real-codebase-the-result-will-surprise-you-5bnj) +- [Fact-checking one AI's audit with another](https://dev.to/mansio/i-asked-one-ai-to-fact-check-another-ais-audit-of-my-own-code-1ac3) +- [The silent vector contamination bug](https://dev.to/mansio/the-silent-vector-contamination-bug-why-your-concurrent-embeddings-might-be-lying-to-you-5fg7) + +## Лицензия + +MIT. См. [LICENSE](../../LICENSE). -## 🙏 Благодарности +## Благодарности -- [Zed IDE](https://zed.dev/) — редактор кода -- [LM Studio](https://lmstudio.ai/) — локальный инференс LLM -- [LanceDB](https://lancedb.github.io/) — векторная база данных -- [Model Context Protocol](https://modelcontextprotocol.io/) — стандарт MCP +Zed, Model Context Protocol, Tree-sitter, SQLite/FTS5 и локальные inference +проекты, которые делают offline-first ассистента практичным. diff --git a/docs/ru/SEARCH_PIPELINE.md b/docs/ru/SEARCH_PIPELINE.md index 8f4aadf8..a8a75a70 100644 --- a/docs/ru/SEARCH_PIPELINE.md +++ b/docs/ru/SEARCH_PIPELINE.md @@ -1,295 +1,224 @@ -# Поисковый пайплайн — Полная техническая документация - -> **Часть MSCodeBase Intelligence** | v3.5.0 - -## Обзор - -Поисковый пайплайн — ядро MSCodeBase. Он объединяет **4 этапа поиска + MultiSignalScorer** для нахождения наиболее релевантного контекста кода. - -```mermaid -flowchart TD - Q[User Query] --> PRE[Preprocessing] - PRE --> INT{Intent Detection} - INT -->|code intent| CW[Code-optimized weights] - INT -->|docs intent| DW[Docs-optimized weights] - INT -->|auto| BW[Balanced weights] - - CW --> PAR[Parallel Search] - DW --> PAR - BW --> PAR - - subgraph PAR[Parallel Search — 2 channels] - BM25[BM25 Sparse\nkeyword match] - DENSE[LanceDB Dense\nsemantic search] - end - - BM25 --> RRF[RRF Fusion\nk=60] - DENSE --> RRF - - RRF --> MULTI[MultiSignalScorer v3.0] - MULTI --> BUCKET[Multi-Bucket RAG] - - subgraph MULTI[MultiSignalScorer — 4 signals] - API[api_signature\nJaccard symbol match] - DIFF[graph_diffusion\nPageRank centrality] - MOD[module_proximity\nhierarchy closeness] - CO2[cochange_boost\ngit coupling] - end - - BUCKET --> GRAPH[Graph expand\ncallees from AST] - GRAPH --> RERANK[Cross-encoder\nbge-reranker-v2-m3] - - RERANK --> CUT[Cut to limit] - CUT --> FMT[Format response] - FMT --> RES[Final Results] +# Поисковый пайплайн — текущий контракт + +> **Актуально на 2026-10-25.** Дефолтный runtime — Phase 3 text-first: +> SQLite FTS5/BM25 плюс PropertyGraph. Dense, LanceDB и reranker-стадии +> опциональны и выключены без конфигурации. Документ описывает реальный +> дефолтный путь, а не старый pipeline как будто он всё ещё основной. + +--- + +## 1. Контракт + +Вход: + +- natural-language или identifier query; +- `mode`: `fast`, `quality`, `deep`, `context`, `ask`, `auto`; +- опционально `layer`, `intent`, `limit`, `explain`. + +Выход: + +- ранжированные chunks/files с metadata (`file`, `chunk_index`, `symbol_name`, + `symbol_kind`, `layer`, line range, source); +- stale-index banner, если freshness check не прошёл; +- опциональные security metadata для недоверенного контента. + +Агент может передать `intent="docs"` или `intent="code"`. Если `intent` пуст, +tool сравнивает лучший docs-only BM25 score с лучшим code score и уходит в +docs-канал, когда разрыв больше `MSCODEBASE_DOCS_MARGIN` (default `4.0`). +LLM в этом решении нет. + +## 2. Дефолтный Phase 3 путь + +```text +query + │ + ├─ normalize / optional RU synonyms + │ + ├─ SQLiteChunkStore.search(query, limit=20..50) + │ └─ FTS5/BM25 по chunk text, symbol_name, docstring, file_path + │ + ├─ если query похож на identifier/path: + │ └─ PropertyGraph stage добавляет exact symbol/file candidates + │ + ├─ optional on-demand rerank (только если настроен) + │ + ├─ exact-name boost + ├─ symbol deduplication + │ + └─ return top-N ``` -## Детали этапов - -### 1. Расширение запроса (Query Expansion) - -```python -_EXPANSION_SYNONYMS = { - "auth": ["authentication", "login", "authorize"], - "error": ["exception", "failure", "bug"], - "create": ["add", "insert", "new"], - # ... ещё 8 групп -} - -def expand_query(query: str, max_expansions: int = 3) -> list[str]: - """Генерирует варианты с синонимами. Каждый вариант ищется независимо.""" - variants = [query] - words = query.lower().split() - for word in words: - synonyms = _EXPANSION_SYNONYMS.get(word, []) - for syn in synonyms[:max_expansions - 1]: - variant = query.replace(word, syn, 1) - if variant not in variants: - variants.append(variant) - return variants -``` +SQLite path намеренно короче исторического LanceDB pipeline. В частности, по +умолчанию он **не** запускает RRF над BM25+dense+graph, MMR, co-change boosts +или P3 module-head anchors. Эти стадии живут в опциональном dense/LanceDB path +или в старых путях. -### 2. BM25 поиск (разреженный) - -- **Назначение:** Точное совпадение по ключевым словам — находит код, содержащий конкретные термины -- **Индекс:** Инкрементальный, строится из чанков LanceDB, хранится как `Dict[doc_id, Dict[term, tf-idf]]` -- **Обновление:** DebounceBatch (500ms) при изменениях файлов, полная перестройка при переиндексации -- **Производительность:** O(log N) на запрос - -```mermaid -flowchart LR - subgraph BM25[BM25 Index Building] - A[LanceDB chunks] --> B[Tokenize + TF] - B --> C[Compute IDF] - C --> D[Score matrix\nterm → doc → tf-idf] - end - subgraph BM25Q[BM25 Query] - Q[Query] --> QT[Tokenize] - QT --> SC[Sum tf-idf per doc] - SC --> TOP[Top-k results] - end -``` +## 3. Источники candidates -### 3. Плотный поиск (векторный, LanceDB) +### 3.1 SQLite FTS5/BM25 -- **Назначение:** Семантическая близость — находит концептуально связанный код -- **Модель:** `multilingual-e5-small-int8` (384-dim) -- **Провайдер:** ONNX INT8 in-process (primary) / LM Studio (fallback) -- **Индекс:** LanceDB v2 с IVF-PQ квантизацией +`SQLiteChunkStore.search` строит FTS5 `MATCH` из терминов запроса (OR, quoted), +ранжирует через SQLite `bm25()` и возвращает: -```python -async def dense_search(query_vector: list, limit: int) -> list: - table = await ensure_async_table() - builder = await table.search(query_vector, vector_column_name="vector") - df = await builder.limit(limit).to_pandas() - return [{"text": row["text"], "metadata": {...}} for _, row in df.iterrows()] -``` +- chunk text и full text; +- file path, chunk index, symbol name/kind, docstring, layer; +- start/end line; +- `final_score` из BM25 rank. -### 4. RRF Fusion (Reciprocal Rank Fusion) - -> ⚠️ **Важно:** Ранги считаются **раздельно** для каждого канала, начиная с 1. -> Объединённый `enumerate(bm25 + dense)` дал бы неверные скоры. - -```python -def rrf_fusion(bm25: list, dense: list, k: int = 60) -> list: - """Слияние результатов BM25 + dense по формуле RRF. - - Ранги считаются отдельно для каждого канала (начиная с 1), - как того требует математически корректная RRF. - """ - scores = {} - results_map = {} - - # BM25 ранги (отдельный enumerate с start=1) - for rank, doc in enumerate(bm25, 1): - key = f"{doc['metadata']['file']}:{doc['metadata']['chunk_index']}" - scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank) - if key not in results_map: - results_map[key] = { - **doc, - "bm25_score": 1.0 / (k + rank), - "dense_score": 0.0, - } - else: - results_map[key]["bm25_score"] = 1.0 / (k + rank) - - # Dense ранги (отдельный enumerate с start=1) - for rank, doc in enumerate(dense, 1): - key = f"{doc['metadata']['file']}:{doc['metadata']['chunk_index']}" - scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank) - if key not in results_map: - results_map[key] = { - **doc, - "bm25_score": 0.0, - "dense_score": 1.0 / (k + rank), - } - else: - results_map[key]["dense_score"] = 1.0 / (k + rank) - - for key in results_map: - results_map[key]["final_score"] = ( - results_map[key]["bm25_score"] + results_map[key]["dense_score"] - ) - - return sorted(results_map.values(), key=lambda x: x["final_score"], reverse=True) -``` +Таблица content-linked (`content='chunks'`) с insert/update/delete triggers: +удаление файла удаляет и его FTS rows. -### 5. Multi-Bucket RAG - -```mermaid -flowchart LR - subgraph Buckets - CODE[Code Bucket\nweight=1.0] - DOCS[Docs Bucket\nweight=1.0] - end - subgraph Intent - CODE_HINT[intent_hint=code\ncode*1.2, docs*0.8] - DOCS_HINT[intent_hint=docs\ncode*0.8, docs*1.2] - AUTO[intent_hint=auto\nneutral] - end - subgraph Extension Detection - EXT[os.path.splitext\n→ .py, .rs = code\n→ .md, .json = docs] - end - RESULTS[Scored results] --> EXT --> |code ext| CODE - EXT --> |docs ext| DOCS - CODE --> |multiply by| CODE_HINT - CODE_HINT --> FINAL[final_score] - DOCS --> |multiply by| DOCS_HINT - DOCS_HINT --> FINAL -``` +### 3.2 Symbol и path signals -### 6. Co-change Boost - -Использует git-историю для повышения файлов, которые исторически изменяются вместе: - -```python -def apply_co_change_boost(chunks: list) -> list: - """Повышает файлы, связанные с топ-3 результатами через git-историю.""" - top_files = {c["metadata"]["file"] for c in chunks[:3]} - matrix = commit_memory.compute_co_change_matrix() - - for chunk in chunks: - file = chunk["metadata"]["file"] - partners = matrix.get(file, {}) - if partners and any(tf in partners for tf in top_files): - best = max(partners.get(tf, 0) for tf in top_files) - chunk["final_score"] *= (1.0 + best * 0.3) - return chunks -``` +Identifier-shaped запросы (`snake_case`, `CamelCase`, paths, `::`) получают +детерминированный graph stage. Граф возвращает exact definition/reference rows, +которые FTS может поставить ниже generic text matches. Дубликаты убираются по +`file + chunk_index`. -### 7. Кросс-энкодер Reranker +### 3.3 Канал документации -**Модель:** `bge-reranker-v2-m3` (через LM Studio / Ollama) +`Searcher.search_docs()` использует тот же FTS5 store и оставляет только +`docs/**`. Он схлопывает языковые варианты (`docs/en/X`, `docs/ru/X`, +`docs/zh/X`) в один ключ, предпочитая язык запроса. Это отдельный канал, а не +изменение ranking кода. -> **Примечание:** Визуализация реранкера предполагает LM Studio (GPU). При использовании ONNX Runtime (fallback) реранкер использует ту же модель `bge-reranker-v2-m3` через ONNX Runtime (CPU) с пониженной пропускной способностью. +### 3.4 Опциональный dense/vector path -- Оценивает каждую пару (запрос, чанк) независимо — **точнее, чем векторный косинус** -- Реранжит только топ-30 кандидатов (контролируется `MAX_RERANKER_INPUT`) -- Корректно переключается на fallback, если LM Studio недоступна +При `MSCODEBASE_DENSE_OFF=false` и доступном provider vector search идёт через +LanceDB или настроенный backend. Этот путь может добавлять candidates, но не +требуется для дефолтного контракта. -```python -async def rerank(query: str, candidates: list, top_n: int = 5) -> list: - """Одноэтапный реранкер: запрос + чанк → оценка релевантности.""" - if not candidates: - return candidates - scores = await multi_reranker.rerank(query, candidates) - for i, chunk in enumerate(candidates): - chunk["final_score"] = scores[i] if i < len(scores) else 0 - return sorted(candidates, key=lambda x: x["final_score"], reverse=True)[:top_n] -``` +## 4. Ranking и fusion + +Дефолтный Phase 3: + +1. BM25 order из SQLite. +2. Graph candidates добавляются для identifier queries. +3. Опциональный on-demand rerank top pool. +4. Exact-name boost для identifier queries. +5. Deduplicate by symbol. -## Полная диаграмма последовательности - -```mermaid -sequenceDiagram - participant Agent as AI Agent - participant MCP as MCP Server - participant EB as @error_boundary - participant ST as SearchTool - participant S as Searcher - participant E as Embedder - participant I as Indexer - participant R as Reranker - - Agent->>MCP: search_code(query, mode="quality", intent_hint="code") - MCP->>EB: wrap(timeout=10000, retries=2) - EB->>ST: execute() - - ST->>S: hybrid_search_async(query, limit=5) - - par Разреженный поиск - S->>I: _bm25_search(query) - I-->>S: BM25 candidates - and Плотный поиск - S->>E: embed_batch_async([query]) - E-->>S: query vector (768-dim) - S->>I: search_async(vector, limit=30) - I-->>S: dense candidates - end - - S->>S: RRF fusion (k=60) - S->>S: Bucket weighting (code intent) - S->>S: Co-change boost - - S->>R: rerank(query, top-15, top_n=5) - R-->>S: reranked scores - - S-->>ST: 5 final results - ST-->>EB: formatted response - EB-->>MCP: success + metadata - MCP-->>Agent: results with file paths +Опциональный/исторический path: + +- reciprocal rank fusion BM25, dense и FTS5; +- bucket weighting по intent и типу файла; +- co-change и exact-symbol boosts; +- non-code demotion (`tests/`, `docs/`, `experiments/`, `fixtures/`); +- MMR diversification; +- identifier anchors и P3 module-head anchors; +- multi-provider reranking. + +Эти стадии конфигурируемы и применяются не к каждому запросу. Не используйте +старое описание pipeline, чтобы предсказать дефолтный порядок результатов. + +## 5. Маршрутизация документации + +Это одно дешёвое сравнение: + +```text +docs_max = лучший BM25 score среди docs/** +code_max = лучший BM25 score среди src/**, tools/**, scripts/**, tests/** +if docs_max - code_max > MSCODEBASE_DOCS_MARGIN: + use search_docs() +else: + use code search ``` -## Бенчмарки производительности - -| Этап | Время | Накопительно | -|-------|:----:|:----------:| -| Расширение запроса | <1ms | <1ms | -| BM25 поиск | ~150ms | ~150ms | -| Эмбеддинг запроса | ~800ms | ~950ms | -| LanceDB ANN | ~400ms | ~1350ms | -| RRF fusion | <1ms | ~1350ms | -| Bucket weighting | <1ms | ~1350ms | -| Co-change boost | ~50ms | ~1400ms | -| Реранкер (5 кандидатов) | ~1200ms | ~2600ms | -| **Итого (quality mode)** | **~5600ms** | | -| **Итого (fast mode, только BM25)** | **~2300ms** | | -| **Итого (deep mode, рекурсивный)** | **2-5s** | | - -> *Замеры с ONNX Runtime (CPU). LM Studio (GPU) может быть в 3-5 раз быстрее.* - -## Конфигурация - -```ini -# .env настройки поиска -DEFAULT_SEARCH_LIMIT=6 -MAX_SEARCH_RESULTS=20 -QUERY_SYNONYMS_ENABLED=true -MAX_QUERY_EXPANSIONS=3 -OVERFETCH_FACTOR=3 -RERANKER_PROVIDERS=ollama,lm_studio - -# Веса корзин (1.0 = нейтрально) -CODE_BUCKET_WEIGHT=1.0 -DOCS_BUCKET_WEIGHT=1.0 +Свойства: + +- один дополнительный FTS-запрос; без вызова модели; +- explicit `intent` важнее эвристики; +- threshold настраивается и подобран на панели 48 запросов; +- code-запрос с сильной docs-лексикой всё ещё может уйти в docs; + `intent="code"` — escape hatch. + +На панели margin `4.0` дал 0 code false positives, docs top-1 в 5/8 docs-вопросов +и 0.792 same-basename hit@1 overall. Это результат панели, не универсальная +константа. + +## 6. Режимы + +| Mode | Путь | Для чего | +|---|---|---| +| `fast` | FTS5 + optional dense; без reranker | Exact names, известные пути, latency | +| `quality` | Дефолтный hybrid path + optional rerank | Обычные code/doc вопросы | +| `deep` | Quality + graph context expansion | Cross-module investigation | +| `context` | Similar-code search по фрагменту | Поиск похожих реализаций | +| `ask` | Retrieval + optional local LLM generation | Только при настроенном LLM profile | +| `auto` | Выбор режима по форме запроса | Дефолтный вход tool | + +`fast` заметно менее точен на смешанных natural-language вопросах, чем +`quality`, на внутренней панели (0.542 vs 0.688 hit@1). Используйте осознанно. + +## 7. Опциональные слои + +| Слой | Флаг / условие | Эффект | +|---|---|---| +| Dense retrieval | `MSCODEBASE_DENSE_OFF=false` + provider | Добавляет vector candidates | +| On-demand rerank | `MSCODEBASE_ONDEMAND_RERANK=true` + provider | Re-score top candidates | +| P3 module-head anchors | `MSCODEBASE_P3_ANCHOR=true` | Добавляет module-head chunks | +| RU synonyms | `MSCODEBASE_RU_SYNONYMS=true` | Расширяет Russian query variants | +| Non-code demotion | `MSCODEBASE_DEMOTE_NON_CODE=true` | Понижает tests/docs/experiments | +| Intent-aware demotion | `MSCODEBASE_DEMOTE_INTENT_AWARE=true` | Оставляет docs для docs intent | +| Docs auto-route | `MSCODEBASE_DOCS_MARGIN` | Выбор docs/code канала | + +## 8. Контракт выдачи + +Каждый результат несёт достаточно metadata для объяснения: + +- `file`, `chunk_index`, `start_line`, `end_line`; +- `symbol_name`, `symbol_kind`, `layer`; +- `final_score` и `source` (`sqlite_fts5`, `graph`, `docs_fts`, ...); +- опционально `docstring`, callers/callees, security flags. + +Ответ tool может содержать stale-index warning. Warning — не ошибка поиска; он +означает, что агенту стоит обновить индекс до того, как доверять свежести. + +## 9. Измеренное поведение + +Внутренняя панель: 48 реальных запросов, clean index, `experiments/**` +исключён, граф присутствует. + +| Путь | hit@1 | hit@5 | +|---|---:|---:| +| Code, quality | 0.825 | 0.900 | +| Docs, exact path | 0.000 | 0.125 | +| Docs, same basename | 0.750 | 0.750 | +| Overall quality | 0.688 | 0.750 | +| Overall auto docs route | 0.792 | 0.875 | +| Fast | 0.542 | 0.729 | + +Границы: + +- это внутренняя, не универсальная панель; +- docs exact-path занижает корректные переводы; +- ни одна статическая конфигурация и ни один протестированный локальный + reranker не дали 95% hit@1; +- two-channel repo map дал около 40/62/73% gold-file coverage на 1k/2k/4k + токенов при идеальном routing; PageRank отдельно — ниже. + +## 10. Отладка поиска + +```text +debug_runtime_passport() +intel_get_runtime_status() +codebase(action="index", path="status") +search_code(query="...", explain=true) ``` + +Полезные проверки: + +1. `chunks.db` не пуст? Если пуст — `scripts/reindex_text_only.py`. +2. `graph.db` пуст? — `scripts/rebuild_graph.py`. +3. Запрос похож на docs? Попробуйте `intent="docs"` / `intent="code"`. +4. Сравните exact-path и same-basename, прежде чем называть docs-результат + промахом. +5. Для `fast` vs `quality` прогоните один запрос в обоих режимах. + +## 11. Non-goals + +- LLM-router на каждый запрос. Routing лексический и дешёвый. +- Статическая repo map вместо итеративного поиска. Это context selection, не + оракул. +- Заявлять 95% retrieval accuracy по маленькой внутренней панели. +- Прятать пустой граф или stale index за уверенным ответом. diff --git a/docs/zh/ARCHITECTURE.md b/docs/zh/ARCHITECTURE.md index 5b00cbcb..c97f806b 100644 --- a/docs/zh/ARCHITECTURE.md +++ b/docs/zh/ARCHITECTURE.md @@ -1,722 +1,229 @@ -[🇬🇧 English](../en/ARCHITECTURE.md) • [🇷🇺 Русский](../ru/ARCHITECTURE.md) • [🇨🇳 中文](ARCHITECTURE.md) +[English](../en/ARCHITECTURE.md) · [Русский](../ru/ARCHITECTURE.md) · [中文](ARCHITECTURE.md) # MSCodeBase Intelligence — 架构指南 -> **版本:** 3.5.0 -> **最后更新:** 2026-08-12 -> **架构:** 4 层架构 + 图原生 PropertyGraph 层 + 数据流层(入口点 → MCP 服务器/DI → 工具类 → 核心业务逻辑 → PropertyGraph → 数据流)带多窗口注册表 + DocSync +> **更新于 2026-10-25。** 本文描述默认的 Phase 3 运行时:SQLite FTS5/BM25 + +> PropertyGraph。Embedding、rerank 和 LanceDB/vector path 是可选的,文中会 +> 明确标注。 + +--- + +## 1. 设计目标 + +服务器让 AI 助手在本地获得有依据的代码库答案,而不强制依赖远程服务或大模型。 + +1. **本地优先。** 搜索、图和项目记忆无需网络。 +2. **确定性优先于概率性。** 先尝试精确符号、路径和 BM25,再使用可选的语义层。 +3. **失败要开放,不能沉默。** 缺少 embedder、空图或过期索引必须给出诊断, + 而不是编造答案。 +4. **人类和 agent 共用一套契约。** Zed、脚本和测试使用同一 tool schema。 +5. **可观测。** Runtime passport、index status、health report 和 logs 是正式 + 工具,不是事后调试附属品。 + +## 2. 运行时概览 + +```text +Zed + ├── built-in LSP (autocomplete, diagnostics) + └── MCP client + │ + ▼ + src/main.py (stdio) 或 src/remote_main.py (HTTP) + │ + ▼ + server_factory.create_mcp_server() + ├── create_service_collection() DI container + ├── register_all_tools() 64 registered tools + └── notification / extension hooks + │ + ├── Search core/search + ├── Index core/indexing + ├── Graph core/graph.py + core/search/graph_adapter.py + ├── Memory core/intelligence + ├── Writes core/modification_guard.py + mcp/tools/write_tools.py + └── Optional providers/embedder, providers/reranker, LSP client + │ + ▼ + Storage + ├── /.mscodebase/chunks.db SQLite + FTS5 + ├── /projects//graph.db PropertyGraph + ├── /projects//intelligence/ memory, incidents + └── /logs/ server logs +``` ---- - -## 目录 - -1. [核心原则](#1-核心原则) -2. [分层架构](#2-分层架构) -3. [DI 容器(ServiceCollection)](#3-di-容器) -4. [工具层(32 核心 + 16 intel + 13 内联 + 4 开发 = 65 个)](#4-工具层) -5. [PropertyGraph 层(v3.0)](#5-propertygraph-层-v30) -6. [Cypher 查询引擎(v3.0)](#6-cypher-查询引擎-v30) -7. [错误处理](#7-错误处理) -8. [速率限制与弹性](#8-速率限制与弹性) -9. [数据流:请求 → 响应](#9-数据流) -10. [Windows 特定处理](#10-windows-特定处理) -11. [多窗口注册表(v2.3+)](#11-多窗口注册表-v23) -12. [测试策略](#12-测试策略) - ---- - -## 1. 核心原则 - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ 四层架构 │ -│ │ -│ 第 1 层: main.py (入口点,最简) │ -│ 第 2 层: mcp/server.py (DI 路由,工具注册) │ -│ 第 3 层: mcp/tools/*.py (32 核心 + 13 内联 + 4 开发) │ -│ 第 4 层: core/*.py (纯业务逻辑) │ -└──────────────────────────────────────────────────────────────────┘ -``` - -**关键规则:** -- **核心层没有 MCP 导入。** 它是纯 Python 业务逻辑。 -- **工具层从不创建依赖。** 一切来自 DI。 -- **server.py 仅注册** — 没有逻辑,没有格式化,没有 try/except。 -- **依赖关系向下流动:** Main ← Server ← Tools ← Core。 - ---- - -## 2. 分层架构 - -### 2.0 十层运行时架构(v2.4) - -``` - Layer 0: Filesystem — 磁盘上有哪些文件? - Layer 1: SystemArtifacts — 这是系统路径吗? - Layer 2: Bridge (LSP→MCP) — [deprecated: LSP server removed 2026-07-20] - Layer 3: Registry (IndexerRegistry) — 哪个 Indexer 拥有此项目? - Layer 4: StateMachine (ProjectState) — 项目处于什么状态? - Layer 5: RuntimeCoordinator — 可以执行此请求吗? - Layer 6: ProjectContext — 项目当前的状态如何? - Layer 7: Passport — 当前运行的是哪个进程? - Layer 8: Intel Layer — 如何处理这些信息? - Layer 9: MCP Tools / AI Agent — 给用户的回答 -``` - -**数据流:** -``` -Filesystem → SystemArtifacts → Bridge → Registry → StateMachine - ↓ -MCP Tools ← Intel Layer ← ProjectContext ← RuntimeCoordinator -``` - -**关键规则:** 工具不直接访问 Registry、Bridge 或 Passport。 -一切通过 `RuntimeCoordinator.can_execute()` + `ProjectContext.capture()`。 - -### 2.1 入口点 - -| 文件 | 协议 | 用途 | -|------|----------|---------| -| `src/main.py` | MCP STDIO | Zed Chat 中的 AI 助手 | -| `src/lsp_main.py` *(已删除)* | LSP STDIO | 已被 LSP 客户端 `src/core/lsp_client.py` 替代 | - -两者都使用相同的 `create_service_collection()` 工厂。 - -### 2.2 MCP 服务器 - -| `src/mcp/server.py` | **~68 行**(重构前为 3,100 行)。 - -职责: -1. 解析项目根目录(`resolve_project_root()`) -2. 创建 DI 容器(`create_service_collection()`) -3. 注册 32 核心 + 16 intel + 13 内联 + 4 开发 = 65 个工具 -4. 注册系统提示(mscodebase-rules) - -**此处没有业务逻辑。** 每个工具都是从 `mcp/tools/` 导入的。 - -### 2.3 工具层 - -`src/mcp/tools/*.py` — **32 个核心 + 13 个内联 + 4 个开发(含 codebase hub + 6 LSP)。** - -每个工具: -- 继承自 `MCPTool`(ABC) -- 通过构造函数接收依赖项(构造函数注入) -- 有且仅有一个入口点:`async def execute(**kwargs) -> dict` -- 使用 `@error_boundary(tool_name, timeout_ms)` 装饰 - -```python -class SearchCodeTool(MCPTool): - """search_code — 语义代码搜索。""" - - def __init__(self, services: ServiceCollection): - super().__init__(services, tool_name="search_code") - self.searcher = services.resolve(Searcher) - self.symbol_index = services.resolve(SymbolIndex) - - @error_boundary("search_code", timeout_ms=15000) - async def execute( - self, - query: str, - mode: str = "auto", - limit: int = 6, - kwargs: Optional[Dict[str, Any]] = None, - ) -> dict: - self.require_index() # 检查索引就绪状态 - # ... 搜索逻辑 -``` - -### 2.4 核心层 - -`src/core/*.py` — **54 个顶层文件**(含子包 123 个)纯业务逻辑。 - -关键模块: - -| 模块 | 路径 | 用途 | -|--------|------|---------| -| `di_container.py` | `src/core/di_container.py` | DI 容器(14 服务) | -| `error_handler.py` | `src/core/error_handler.py` | ToolError + error_boundary | -| `rate_limiter.py` | `src/core/rate_limiter.py` | DebounceBatch + CircuitBreaker | -| `engine.py` | `src/core/search/engine.py` | 混合搜索(BM25 + Dense + FTS5 + RRF) | -| `graph.py` | `src/core/graph.py` | PropertyGraph — SQLite 属性图 | -| `graph_adapter.py` | `src/core/search/graph_adapter.py` | 包装 PropertyGraph 的 SymbolIndexAdapter | -| `cypher_engine.py` | `src/core/search/cypher_engine.py` | PropertyGraph 的 Cypher→SQL 引擎 | -| `indexer.py` | `src/core/indexing/indexer.py` | LanceDB 向量存储 + 索引化流水线 | -| `symbol_index.py` | `src/core/indexing/symbol_index.py` | 调用图(BFS, PageRank) | -| `parser.py` | `src/core/indexing/parser.py` | Tree-sitter AST 解析器(16 种语言) | -| `file_guard.py` | `src/core/indexing/file_guard.py` | .gitignore + 扩展过滤器 | -| `db_manager.py` | `src/core/indexing/db_manager.py` | LanceDB 表生命周期(PID 锁,重新索引守卫) | -| `fts5_mixin.py` | `src/core/search/fts5_mixin.py` | FTS5 全文搜索 mixin | -| `scoring.py` | `src/core/search/scoring.py` | RRF + MMR 多样性评分 | -| `layer.py` | `src/core/intelligence/layer.py` | Intel 层(14 个 intel_* 工具) | -| `runtime_coordinator.py` | `src/core/runtime_coordinator.py` | ExecutionVerdict + can_execute() | -| `project_context.py` | `src/core/intelligence/project_context.py` | 项目状态快照 | -| `llama_runner.py` | `src/providers/reranker/llama_runner.py` | llama-server.exe(重排序器)生命周期 | -| `remote_embedder.py` | `src/providers/embedder/remote_embedder.py` | llama.cpp GGUF 嵌入器(首选)+ ONNX INT8 / LM Studio / Ollama 回退 | -| `doc_sync_engine.py` | `src/core/doc_sync_engine.py` | 自动同步文档与代码(重命名钩子) | - -### 2.5 搜索引擎(v3.3) - -``` -┌─────────────────────────────────────────────────────────┐ -│ 搜索流水线(engine.py) │ -│ │ -│ query_str → embed() → BM25 → FTS5 → 3-way RRF → MMR │ -│ ↕ │ -│ MultiSignalScorer: api_signature, graph_diffusion, │ -│ module_proximity, cochange_boost │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ PropertyGraph(graph.py) │ -│ SQLite(WAL + mmap),节点/边,JSON 属性 │ -│ — 15 个节点标签(File, Function, Class, Variable...) │ -│ — 29 种边类型(CALLS, DEFINES, ASSIGNED_FROM, ...) │ -│ — Cypher 查询引擎(MATCH→SQL) │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ 数据流层(v3.2.0) │ -│ │ -│ 1. 统一 Walker — 一次 Tree-sitter 解析 → 调用 + │ -│ 赋值。带内容哈希的解析缓存。 │ -│ 2. 条件流 — ASSIGNED_FROM 边具有 │ -│ condition_path(if/for/while/try/except) │ -│ 3. 仅过程内 — 在函数体内部 │ -│ 4. 16 种语言:Python, Rust, TS, TSX, Go, JS, │ -│ Java, C#, Ruby, PHP, Kotlin, Swift, C, C++, │ -│ Scala, Dart │ -└─────────────────────────────────────────────────────────┘ -``` - -### 2.6 嵌入器(Embedder):llama.cpp GGUF(原生,首选),ONNX INT8 回退 - -MCP 服务器现在使用 multilingual-e5-small 通过 **llama.cpp GGUF**(`llama-server.exe`)作为其主要嵌入器(embedder);**ONNX Runtime(CPU,进程内)** 作为 llama.cpp 不可用时的回退(启动时自动检测 — 一旦 llama.cpp 就绪,ONNX 预加载被取消): - -- **模型:** `intfloat/multilingual-e5-small`(384 维),GGUF;回退 — INT8 ONNX -- **运行时:** llama.cpp(`:8080`,llama-server);回退 — ONNX 进程内 -- **架构:** llama.cpp 为独立进程;ONNX 进程内仅在回退时使用 -- **性能:** ~16-50 ch/s(llama.cpp CPU,取决于负载) -- **RAM:** ~1.7 GB(2× llama-server:嵌入 + 重排序);ONNX 回退 ~265 MB -- **配置:** 模型自动检测(GGUF 来自 `models/`);遗留 `EMBEDDING_DIMENSION`/`EMBEDDING_PROVIDER` 不再使用 +`` 在 Windows 上是 `%LOCALAPPDATA%/mscodebase`,其他平台使用对应 +位置。项目产物在仓库之外;`chunks.db` 是唯一留在项目目录内的存储。 + +## 3. 分层 + +| 层 | 职责 | 主要模块 | +|---|---|---| +| Entry | 进程启动、project root、stdio/HTTP | `src/main.py`, `src/remote_main.py` | +| Protocol | MCP server、lifecycle、hooks | `src/mcp/server.py`, `server_factory.py` | +| Tool | 薄适配器:校验、格式化、error boundary | `src/mcp/tools/*.py`, `server_tools.py` | +| Core | Search、indexing、graph、memory、write guards | `src/core/*` | +| Providers | 可选 embedders、rerankers、LSP | `src/providers/*`, `src/core/lsp_client.py` | +| Storage | SQLite/FTS5、PropertyGraph、JSON memory、logs | `chunk_store.py`, `graph.py`, `intelligence/` | -重排序器(reranker)仍然通过 llama-server 运行(1 个进程,不是 2 个)。 +Tool layer 不实现 ranking 或 parsing,只解析 project-scoped service 并委托 +给 core。 -传统的回退(fallback)提供者(provider)(LM Studio、Ollama、远程 ONNX)仍然可通过 `remote_embedder.py` 用于自定义配置。 +## 4. 入口与生命周期 ---- +- `src/main.py` — Zed 扩展使用的 stdio MCP server:解析 extension/project + root、配置 logging、启动 server loop。 +- `src/remote_main.py` — 同一 server 的 authenticated HTTP transport,可选, + 未配置时关闭。 +- `src/mcp/server_factory.py` — 创建 server、DI container、注册 tools、 + notifications 和 shutdown handlers。 +- `src/mcp/server_tools.py` — 注册和 tool allowlist。 -## 3. DI 容器 +另一个 Zed 窗口中的项目由 `ProjectIndexerRegistry` 隔离:每个项目有独立的 +indexer/searcher/graph,带 LRU 上限和 resource monitoring。 -### 3.1 ServiceCollection +## 5. 依赖注入 -```python -# src/core/di_container.py +`src/core/di_container.py` 提供 `ServiceCollection` 和 +`create_service_collection(project_root)`。服务是进程内 singleton: -services = ServiceCollection() +- project root 和 parser; +- indexer/searcher 和可选 embedder; +- PropertyGraph 和 symbol index; +- `ProjectIndexerRegistry` / `MultiProjectSearcher`; +- rate limiter、circuit breaker、notification broker。 -# 注册单例: -services.add_singleton(Indexer, indexer_instance) +Tool class 通过 constructor injection 获取 collection,不直接创建 indexer 或 +打开数据库。Shutdown 关闭资源并释放后台任务。 -# 注册懒工厂: -services.add_factory(Searcher, lambda s: Searcher(s.resolve(Indexer), ...)) +## 6. Tool 层 -# 解析: -indexer = services.resolve(Indexer) # 每次都是同一个实例 -``` +服务器注册 **64 个工具**: -### 3.2 已注册服务(11 个) +- 31 个 core tool class(search、LSP、graph、analysis、write、lifecycle); +- 16 个 `intel_*`; +- 13 个 inline diagnostic; +- 4 个 dev。 + +`execute_script` 是第 65 个可选工具,仅在 +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true` 时注册。默认 allowlist 暴露 **49** +个;`MSCODEBASE_MCP_TOOLS` 控制可见性(空值显示全部已注册工具)。 + +`codebase(action=...)` 是 index、git、write 的 hub。搜索有 `search_code` 和 +`plan_search`,后者返回确定性计划(primary query、exact tokens、symbols、 +concepts、intent)。 + +所有 tool 由 `error_boundary` 包裹:timeout、telemetry 和结构化错误,避免 +挂住 MCP loop。 + +## 7. 索引流水线 + +```text +discover files + → FileGuard (extensions, size, binaries, system dirs, .gitignore) + → parser (Tree-sitter; language-specific extractors) + → chunks + symbols + calls/imports/assignments/decorators/overrides + → SQLiteChunkStore (text + FTS5) and PropertyGraph +``` + +规则: + +- `experiments/**` **不进入索引**。其中是 frozen 面板和运行产物;索引它们曾 + 让 BM25 返回面板本身而不是代码。文件仍保留在 git。 +- Incremental indexing 在一个事务中更新文件的 chunks 和 graph rows。 +- `MSCODEBASE_DENSE_OFF=true` 时完整 vector reindex 被有意阻止。文本用 + `scripts/reindex_text_only.py`,图用 `scripts/rebuild_graph.py`。 +- `build_head` 只在完整成功索引后写入,便于与 git HEAD 比对 freshness。 +- `FileGuard` 和 `SafePathManager` 在 parsing 前拒绝项目外路径和系统目录。 + +## 8. 存储 + +| Store | 路径 | 用途 | +|---|---|---| +| Chunk store | `/.mscodebase/chunks.db` | 文本、metadata、FTS5 | +| Graph | `/projects//graph.db` | 符号、文件、typed edges | +| Memory | `/projects//intelligence/` | 项目记忆、事故、jobs | +| Logs | `/logs/` | server/tool 诊断 | -| # | 服务 | 类型 | 创建者 | -|---|---------|------|------------| -| 1 | ProjectRootKey | 单例 | `project_root`(哨兵键) | -| 2 | CodeParser | 单例 | `CodeParser()` | -| 3 | RemoteEmbedder | 单例 | `RemoteEmbedder()` | -| 4 | ProjectRegistry | 单例 | `ProjectRegistry()` | -| 5 | MultiProjectSearcher | 单例 | `MultiProjectSearcher(embedder, registry)` | -| 6 | PropertyGraph | 单例 | `PropertyGraph(.codebase/graph.db)` | -| 7 | ProjectIndexerRegistry | 单例 | `ProjectIndexerRegistry(max_cached=5)` | -| 8 | NotificationBroker | 单例 | `NotificationBroker()` | -| 9 | IndexerFactoryKey | 工厂 | `_create_indexer_for_path` | -| 10 | SlidingWindowRateLimiter | 单例 | `SlidingWindowRateLimiter()` | -| 11 | CircuitBreaker | 单例 | `CircuitBreaker(name="lm_studio")` | +LanceDB 是 legacy/optional vector store。默认 text-first path 不需要它,在 +`MSCODEBASE_SQLITE_CHUNKS=true` 时也不是主要 candidate 来源。 -> 清理(任务 2/5):已删除未使用的注册 DbPathKey、FileGuard、SymbolIndex、ResourceMonitor、ResourceMonitorKey(从未解析)。 +## 9. 搜索集成 ---- +搜索是最大的 core 子系统。默认路径: -## 4. 工具层 - -### 4.1 工具注册 - -在 `src/mcp/server_tools.py` 中: - -```python -def register_all_tools(mcp, services): - tool_classes = [ - # 搜索(3 个) - SearchCodeTool, GetSymbolInfoTool, ImpactAnalysisTool, - # Hub: codebase(按 action 多路复用 — write/index/git/notify) - CodebaseTool, - # Spoke: AST 沙箱(3 层:AST 验证 + 运行时 __import__ 包装器 + 子进程隔离) - ExecuteScriptTool, - # 分析(5 个) - StructuralSearchTool, GetRepoMapTool, GetRepoRankTool, - ScanChangesTool, GenerateChunkSummariesTool, - # 图(3 个 — Phase 2: graph_query 多路复用 cypher + related + flow) - CrossRepoSearchTool, CrossProjectDepsTool, GraphQueryTool, - # 调查(3 个) - GetBugCorrelationTool, GetHotspotsTool, FindSimilarBugsTool, - # 生命周期(3 个) - SubmitBackgroundTaskTool, GetTaskStatusTool, VerifyActionTool, - ] - # +14 个 intel_* 工具 + 12 个内联诊断 + 4 个开发 - # 总计:61 个已注册(28 核心 + 16 intel + 13 内联 + 4 开发) -``` +1. SQLite FTS5/BM25 candidates; +2. identifier-shaped 查询的可选 symbol/graph stage; +3. 可选 on-demand rerank; +4. exact-name boost 和 symbol deduplication; +5. BM25 分差足够大时,在 tool 层做 docs routing。 -**工具可见性过滤器:** 默认可见集合为 `MSCODEBASE_MCP_TOOLS` allowlist(见 `src/mcp/server_tools.py`)。设置 `MSCODEBASE_MCP_TOOLS=""` 以显示全部 65 个(启用 `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true` 时 +1 `execute_script`)。 - -### 4.2 按组分组的全部工具 - -| 组 | 文件 | 工具 | -|-------|------|-------| -| **搜索**(3 个) | `search_tools.py` | search_code, get_symbol_info, impact_analysis | -| **LSP**(6 个) | `lsp_tools.py` | lsp_find_references, lsp_find_definition, lsp_document_symbols, lsp_get_type_info, lsp_get_diagnostics, lsp_get_code_actions | -| **Hub: codebase**(1 个) | `codebase_tool.py` | codebase(action=index/git/rename/move/delete/replace/insert/notify) | -| **Spoke: Sandbox**(1 个) | `codebase_tool.py` | execute_script(code) — AST + 运行时 import wrapper + 子进程隔离(可选,env 标志) | -| **分析**(5 个) | `analysis_tools.py` | structural_search, get_repo_map, get_repo_rank, scan_changes, generate_chunk_summaries | -| **图**(4 个) | `graph_tools.py` | cross_repo_search, cross_project_deps, graph_query, detect_communities | -| **调查**(3 个) | `investigation_tools.py` | get_bug_correlation, get_hotspots, find_similar_bugs | -| **重复**(1 个) | `duplication_tool.py` | find_duplicates | -| **上下文**(1 个) | `context_tool.py` | get_context | -| **生命周期**(3 个) | `lifecycle_tools.py` | submit_background_task, get_task_status, verify_action | -| **Doc**(1 个) | `doc_tools.py` | stale_detector | -| **Dev**(4 个) | `dev_tools.py` | generate_docs, bump_version, auto_update_docs, install_git_hooks | -| **智能层**(14 个) | `intelligence/tools_reg.py` | intel_get_runtime_status, intel_trigger_reindex, intel_reset_index, intel_get_job_status, intel_code_topology, intel_log_incident, intel_get_project_memory, intel_add_memory_node, intel_auto_collect_adrs, intel_get_hotspots, intel_analyze_incident, intel_predict_root_cause, intel_get_telemetry, intel_retract_memory_node | -| **诊断内联**(12 个) | `server_tools.py` | debug_runtime_passport, intel_get_project_context, intel_explain_project_state, get_runtime_counters, intel_tool_health, intel_execution_timeline, refresh_db_connection, notify_change, read_live_file, get_logs, get_health_report, ack_impact | - -> **总计:** 65 个已注册(32 核心 + 16 intel + 13 内联 + 4 开发)。默认可见:`MSCODEBASE_MCP_TOOLS` allowlist(见 `src/mcp/server_tools.py`)。显示全部:`MSCODEBASE_MCP_TOOLS=""`。 +`core/search/engine.py` 中完整的 RRF/MMR/anchor pipeline 属于可选 +dense/LanceDB path 或旧路径,不会静默应用到每个 Phase 3 查询。精确划分见 +[SEARCH_PIPELINE.md](SEARCH_PIPELINE.md)。 ---- +## 10. Intelligence 与记忆 -## 5. PropertyGraph 层(v3.0) +`core/intelligence/` 实现项目记忆和事故处理: -PropertyGraph 是系统的语义骨干,在 `.codebase/graph.db` 中存储: +- ADR 和 known issues 以带状态和 evidence 的记录保存; +- 事故可记录、搜索,并基于 runtime data 预测; +- `verify-on-read` 可将记忆标记为 verified/refuted/superseded; +- retraction/supersede 保留历史,不删除。 -| 组件 | 文件 | 用途 | -|-----------|------|---------| -| **PropertyGraph** | `src/core/graph.py` | SQLite(WAL + mmap),15 个节点标签,29 种边类型 | -| **SymbolIndexAdapter** | `src/core/search/graph_adapter.py` | 将 PropertyGraph 包装为只读 SymbolIndex | -| **CypherEngine** | `src/core/search/cypher_engine.py` | Cypher → SQL 转换(MATCH→JOIN, RETURN→SELECT) | -| **RouteExtractor** | `src/core/graph.py` | 从 AST 中提取 HTTP 路由 | +记忆是本地、project-scoped 的,不是通用聊天日志。 -**支持的图谱操作:** +## 11. 写入与安全 -| 操作 | Cypher 示例 | 用途 | -|-----------|-------------|---------| -| 调用者 | `MATCH (c)-[:CALLS]->(f) WHERE f.name='func' RETURN c` | "谁调用了 func?" | -| 被调用者 | `MATCH (f)-[:CALLS]->(c) WHERE f.name='func' RETURN c` | "func 调用了谁?" | -| 定义者 | `MATCH (c)-[:DEFINES]->(f) WHERE f.name='func' RETURN c` | "func 在哪里定义的?" | -| 赋值来源 | `MATCH (s)-[e:ASSIGNED_FROM]->(t) WHERE t.name='x' RETURN s` | "x 从哪里来?" | -| 数据流图 | `MATCH (s)-[*1..3]->(t) WHERE ...` | 多跳数据流追踪 | -| 拓扑 | `intel_code_topology(symbol)` | 架构调用图 | +Write tools(`rename`、`move`、`safe_delete`、`replace`、`insert_before/after`) +操作符号,而不是裸文本。流程: ---- +1. 解析 symbol 和 references; +2. 生成 impact summary; +3. load-bearing 文件要求 `ack_impact`; +4. preview; +5. 仅在 `apply=true` 时应用,然后刷新 chunks/graph。 -## 6. Cypher 查询引擎(v3.0) +跨文件 rename 可使用 basedpyright LSP;timeout 时回退 SymbolIndex。 +`execute_script` 默认关闭;启用时使用 AST validation、import wrapper 和 +subprocess sandbox。 -`CypherEngine` 将 Cypher AST 转换为 SQL 查询: +## 12. 并发与多窗口 -**支持的 Cypher 模式:** +- 每个项目一个 indexer/searcher,通过 registry 解析。 +- SQLite writes 由 chunk store lock 串行化;reads 跨线程安全。 +- 后台索引使用 watchdog heartbeat 和 job state,agent 轮询而不阻塞编辑器。 +- 外部进程使用 `Popen` + `communicate(timeout=...)`,不在 daemon thread 中用 + `capture_output`,避免 Windows pipe deadlock。 +- Reindex fast-fail 返回明确的“索引重建中”错误,而不是挂起。 -| 模式 | SQL 等效 | 示例 | -|---------|-------------|---------| -| `MATCH (n)` | `SELECT * FROM nodes` | 所有节点 | -| `WHERE n.prop = val` | `WHERE json_extract(...) = val` | 属性过滤 | -| `MATCH (a)-[e]->(b)` | `JOIN edges ON a.id=e.source AND b.id=e.target` | 边遍历 | -| `RETURN n.name, n.kind` | `SELECT json_extract(...)` | 投影 | -| `LIMIT 10` | `LIMIT 10` | 分页 | - -**用法:** - -```python -engine = CypherEngine(graph_db_path) - -# 原始查询 -rows = engine.query("MATCH (f:Function) WHERE f.name='parse' RETURN f.name, f.file_path") - -# 通过 graph_query 工具 -tool_result = graph_query(query="MATCH (f)-[:CALLS]->(g) RETURN f.name, g.name", limit=20) -``` +## 13. 故障与运维检查 ---- - -## 7. 错误处理 - -### 7.1 error_boundary 装饰器 - -每个工具都使用 `@error_boundary` 包装: - -```python -@error_boundary("tool_name", timeout_ms=15000, max_retries=1) -async def execute(self, **kwargs) -> dict: - ... -``` - -它保证: -1. **真实超时** 通过 `asyncio.wait_for(timeout_ms / 1000.0)` -2. **统一 JSON** 始终为:`{"status": "ok"|"error"|"timeout"|"warning", "message": "...", "detail": "...", "latency_ms": 123}` -3. **受控错误**(`ToolError`)→ 原样返回,不重试 -4. **意外错误** → 记录完整回溯,返回 `"status": "error"` -5. **超时重试** — 可通过 `max_retries` 配置 - -### 7.2 ToolError 层次结构 - -```python -ToolError # 基类:status, message, detail, recoverable -├── IndexNotReadyError # 索引为空(警告,可恢复) -└── RateLimitError # 速率限制超出(警告,可恢复) -``` +| 现象 | 首先检查 | 修复 | +|---|---|---| +| Graph tools 无结果 | `graph.db` 的 nodes/edges | `python scripts/rebuild_graph.py` | +| 搜索返回 experiment docs | 索引含 `experiments/**` | reindex;FileGuard 已排除 | +| Docs 总是 miss | gold 是 `docs/en`,结果是 `docs/ru` | 按 basename 归一化 | +| Index stale | `codebase(action="index", path="status")` | `scripts/reindex_text_only.py` | +| Embedder missing | `debug_runtime_passport()` | text path 仍可用;配置 provider | +| Tools 不可见 | `MSCODEBASE_MCP_TOOLS` | 添加名称或设为空 | ---- - -## 8. 速率限制与弹性 - -### 8.1 SlidingWindowRateLimiter - -```python -limiter = SlidingWindowRateLimiter() # asyncio.Lock 用于线程安全 - -ok = await limiter.acquire("notify_change", max_per_sec=10.0) -if not ok: - raise RateLimitError(detail="Too many notify_change calls") -``` - -### 8.2 DebounceBatch - -替代每次文件更改时立即调用 `searcher.reindex()`: - -```python -batch = DebounceBatch(callback=searcher.reindex, config=DebounceConfig( - debounce_ms=500, # 最后一个事件后 500ms - max_batch_size=100, # 或在 100 个文件时立即刷新 - max_wait_ms=5000, # 防止无限防抖 -)) -await batch.add("file.py") # BM25 在 500ms 后(或在 100 个文件时)重建 -``` - -### 8.3 CircuitBreaker - -```python -cb = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0, name="lm_studio") - -result = await cb.call( - lambda: embedder.embed_batch(texts), - fallback={"status": "fallback", "message": "LM Studio unavailable"} -) -# 状态:CLOSED → OPEN(5 次失败)→ HALF_OPEN(30 秒后)→ CLOSED(成功时) -``` - ---- - -## 9. 数据流 - -``` -Zed AI Agent - │ - ▼ -MCP 工具调用(例如 search_code("find indexer")) - │ - ▼ -error_boundary 装饰器 - ├── 超时检查(asyncio.wait_for) - ├── 速率限制检查(SlidingWindowRateLimiter) - └── 工具执行 - │ - ▼ - MCPTool.execute(**kwargs) - │ - ├── self.require_index() → 如果为空则 IndexNotReadyError - ├── services.resolve(Searcher) - ├── engine.hybrid_search(query) - │ │ - │ ▼ - │ core/search/engine.py - │ ├── BM25 搜索(内存 TF-IDF) - │ ├── 向量搜索(LanceDB + llama.cpp GGUF,ONNX 回退) - │ ├── FTS5 搜索(SQLite FTS5,trigram+porter) - │ └── 3 路 RRF 融合 + MMR 多样性 - │ - └── return {"status": "ok", "results": [...]} - │ - ▼ - error_boundary → {"status": "ok", ...latency_ms} - │ - ▼ - Zed Chat(格式化的 JSON 响应) -``` +## 14. 测试与验证 ---- - -## 10. Metadata Enrichment(v2.4.4+) - -### 10.1 Chunk Metadata - -每个存储在 LanceDB 中的块(chunk)包含 6 个元数据字段,用于确定性 -过滤和多粒度检索: - -| 字段 | 类型 | 示例 | 用途 | -|-------|------|---------|---------| -| `layer` | string | `"core"` | 架构层:core/mcp/utils/tests/... | -| `module_name` | string | `"core.parser"` | 从文件路径派生的逻辑模块名 | -| `hierarchy_level` | string | `"method"` | 层级:function/method/class/impl/lines | -| `is_public` | bool | `true` | 公开/私有(以 `_` 开头) | -| `symbol_type` | string | `"method_definition"` | AST 节点类型 | -| `parent_id` | string | md5 哈希 | 确定性父哈希 | - -层检测 — 自动,按文件路径: - -| 路径 | layer | -|------|-------| -| `src/core/*` | `core` | -| `src/mcp/tools/*` | `mcp_tools` | -| `src/mcp/*` | `mcp` | -| `src/utils/*` | `utils` | -| `tests/*` | `tests` | -| `docs/*` | `docs` | -| `.agents/*` | `agents` | -| `scripts/*` | `scripts` | -| `.github/*` | `ci` | -| 其他 | `root` | - -### 10.2 扁平树层次结构(SproutRAG 风格) - -`parent_id` — 确定性 md5 哈希: - -- **对于方法:** `md5(file_path + "::" + class_name)` — 父级 = 类 -- **对于函数:** `md5(file_path)` — 父级 = 模块 -- **对于巨型函数部分:** `md5(file_path + "::" + symbol_name)` — 父级 = 函数 - -无需图数据库即可实现多粒度检索: -- 查找类的所有方法 → `get_chunks_by_parent_id("md5_hash")` -- 向上导航到模块 → 按 parent_id 聚合 - -### 10.3 search_code 中的层过滤 - -```python -# 仅核心层 -search_code(query="DI container", filter_layer="core") - -# 仅测试 -search_code(query="test_parser", filter_layer="tests") - -# 无过滤(所有层,与之前一样) -search_code(query="parser") -``` - -层过滤在 LanceDB 级别通过 `.where(prefilter=True)` 工作 — 向量搜索仅搜索指定层的块(chunk)。BM25 从元数据中按层进行后过滤。 - ---- - -## 10. Windows 特定处理 - -### 10.1 路径解析 - -`PROJECT_PATH` 可能包含 `$ZED_WORKTREE_ROOT` 字面字符串(Zed 在 Windows 上未解析环境变量)。 -解决方案:`resolve_project_root()` 检查 7 种回退(fallback)策略: - -1. 提供的参数 -2. LSP→MCP 桥接器(bridge)(来自 LSP 的临时文件,后者知道 `root_uri`) -3. `PROJECT_PATH` 环境变量(如果不是 `$ZED` 则已解析) -4. 如果 `ext_root` 是 git 仓库 -5. `ZED_WORKTREE_ROOT` 环境变量 -6. CWD(来自 Zed `settings.json`) -7. `ext_root` 作为最终回退(fallback) - -### 10.2 Git 子进程安全 - -```python -env["GIT_TERMINAL_PROMPT"] = "0" # 无交互式提示 -env["GIT_ASKPASS"] = "echo" # 无凭据助手 -env["GIT_PAGER"] = "cat" # 无分页器 -creationflags = subprocess.CREATE_NO_WINDOW # 无控制台窗口 -``` - -### 10.3 长路径支持 - -SafePathManager 对超过 260 个字符的路径使用 `to_win_long_path()`(在前面加上 `\\?\`)。 - ---- - -## 11. 多窗口注册表(v2.3+) - -v2.3+ 支持 **同时在 Zed 中打开多个项目**。 -以前 DI 持有一个单例 `Indexer` — 切换窗口时,状态会损坏 -(一个 `file_guard`,一个 `db_path`,共享的 `SymbolIndex`)。 - -### 11.1 `ProjectIndexerRegistry` - -`src/core/indexing/project_indexer_registry.py` — 线程安全的 `Indexer` 对象注册表: - -```python -registry = ProjectIndexerRegistry( - max_cached=5, # LRU 限制(5 个项目 = 1-2.5GB RAM) - resource_monitor=get_global_resource_monitor(), # 自适应节流 -) - -# 每个项目通过工厂懒创建: -def _create_indexer(p: Path) -> Indexer: - return Indexer( - db_path=_generate_unique_db_path(p), - file_guard=FileGuard(p), - symbol_index=SymbolIndex(), # 隔离 - project_path=p, ... - ) - -services.add_singleton(IndexerFactoryKey, _create_indexer) -indexer = registry.get_indexer(project_path, factory=_create_indexer) -``` - -**保证:** -- **隔离性:** 每个窗口获得自己的 `FileGuard`/`SymbolIndex`/`db_path`。 -- **LRU:** 当第 6 个项目打开时,最旧的 `Indexer` 被驱逐。 -- **压力驱逐:** 当 RAM > 1GB 或 CPU > 85% 时 — 在创建新的 `Indexer` **之前**强制驱逐(防止 OOM)。 -- **清理:** `_safe_close()` 重置 LanceDB 连接 + `gc.collect()`(用于 Windows mmap 句柄)。 - -### 11.2 `ResourceMonitor` - -`src/core/indexing/resource_monitor.py` — 仅使用标准库的监控(无 `psutil`): - -| 平台 | 方法 | -|-----------|-------| -| POSIX | `resource.getrusage(RUSAGE_SELF).ru_maxrss` | -| Windows | `psapi.GetProcessMemoryInfo` 通过 `ctypes` | -| CPU | `resource.getrusage` utime+stime 增量 / 挂钟时间 | - -**阈值:** -- 软阈值:768MB / 75% CPU → 节流索引(文件间延迟 0.1s) -- 硬阈值:1024MB / 85% CPU → 压力驱逐 + 0.5-2s 延迟 - -```python -monitor = get_global_resource_monitor() -snap = monitor.sample() # ResourceSnapshot (rss_mb, cpu_percent, threads) - -if monitor.is_under_pressure(): - delay = monitor.suggest_throttle_delay_sec() - time.sleep(delay) # 在 Indexer.index_project 中文件之间 -``` - -### 11.3 Per-workspace DI(多窗口) - -独立 LSP(`src/lsp_main.py`)**已删除**(WONTFIX,见 -`docs/en/investigations/LSP_WONTFIX.md`)。按项目 DI 由 -`ProjectIndexerRegistry`(v2.3+)管理: - -```python -registry: ProjectIndexerRegistry = services.resolve(ProjectIndexerRegistry) -indexer = registry.get_indexer(project_path, factory=factory) -# → 创建隔离的 per-project Indexer(LRU max_cached=5 + 内存压力驱逐) -``` - -每个窗口/工作区都有自己的 Indexer,拥有独立的 LanceDB 数据库和 -PropertyGraph(路径哈希到项目外部 — 任务 4/5)。 - -### 11.4 MCP `resolve_indexer_for_request` - -`src/mcp/tools/base.py` — 按项目 Indexer 的单一入口点: - -```python -def resolve_indexer_for_request(services, explicit_project_root=None): - target = explicit_project_root or resolve_project_root() or DI_default - registry = services.resolve(ProjectIndexerRegistry) - factory = services.resolve(IndexerFactoryKey) - return registry.get_indexer(target, factory=factory) - -class MCPTool: - def resolve_indexer(self, project_root=None): - return resolve_indexer_for_request(self._services, project_root) -``` - -**所有 MCP 工具** 必须使用 `self.resolve_indexer(...)` -而不是 `self._services.resolve(Indexer)` — 后者不再有效 -(Indexer 不是单例)。 - -### 11.5 HealthReport `_check_resources` - -`src/core/code_health.py` — 新增方法: - -```python -def _check_resources(self): - summary = get_global_resource_monitor().get_summary() - self.metrics["process_rss_mb"] = summary["rss_mb"] - self.metrics["process_cpu_percent"] = summary["cpu_percent"] - self.metrics["registry_cached_projects"] = ... - self.metrics["registry_evictions"] = ... - if summary["under_hard_pressure"]: - self.issues.append({...}) -``` - ---- - -## 12. 测试策略 - -``` -tests/ -├── test_error_handler.py # 18 个测试 — ToolError, error_boundary -├── test_rate_limiter.py # 21 个测试 — SlidingWindow, DebounceBatch, CircuitBreaker -├── test_di_container.py # 13 个测试 — ServiceCollection, 11 services -├── test_resource_monitor.py # 11 个测试 — ResourceMonitor + ProjectIndexerRegistry (v2.3+) -├── test_parser.py # 4 个测试 — Tree-sitter 解析 -├── test_execution_contract.py# 10 个测试 — verify_action -├── test_task_queue.py # 6 个测试 — 后台任务队列 -├── test_branch_aware_index.py# 8 个测试 — get_branch_info -├── test_symbol_index_call_graph.py # 8 个测试 — 调用图 -├── ...(还有 20 多个测试文件) -``` - -**总计:853 个测试。** - -运行: -```bash -pytest tests/ -m "not integration and not benchmark" -``` - ---- - -## 快速参考 - -| 命令 | 描述 | -|---------|-------------| -| `python -m src.main` | 运行 MCP 服务器(STDIO) | -| `pytest tests/` | 运行所有测试 | -| `pytest tests/test_di_container.py -v` | 仅运行 DI 容器测试 | -| `python -c "from src.mcp.server import create_mcp_server; mcp = create_mcp_server()"` | 验证服务器加载 | - ---- - -## 13. 架构不变规则 - -以下规则在任何新的 PR 中不得被违反。 - -``` -1. 工具不直接访问 Registry。 -2. 工具不直接读取 Bridge。 -3. 工具仅通过 RuntimeCoordinator 工作。 -4. RuntimeCoordinator 不了解 Search / Indexer / Memory。 -5. ProjectContext 是不可变快照(不启动操作)。 -6. 所有系统文件仅通过 SystemArtifacts 定义。 -7. 索引器从不索引系统工件。 -8. 任何项目路径都通过单一解析器(resolve_project_root)。 -9. 所有 Intel 工具使用 ProjectContext(而非低级 API)。 -10. 任何新的运行时组件必须具有单一职责。 -11. 核心层没有 MCP 导入。 -12. 工具不创建依赖 — 一切通过 DI。 -13. server.py 仅注册 — 不包含业务逻辑。 -``` +- `pytest tests/` — unit/integration suite;markers 区分 integration 和 + benchmark。 +- `python scripts/smoke_e2e.py --project .` — 真实 embed/rerank/search,无 + mock,provider 可用时执行。 +- `scripts/rebuild_graph.py` 和 `scripts/reindex_text_only.py` 是 graph/index + drift 的官方修复路径。 +- Search experiments 在 `experiments/`,含 frozen inputs 和 raw runs;README + 中的质量数字来源于此。 + +## 15. 相关文档 -**代码审查检查:** 每个 PR 必须回答问题 -"此扩展属于哪个现有层?"。如果答案是"没有,我创建了一个 -新的 Manager/Services/Provider" — 这是停下来重新考虑的理由。 +- [SEARCH_PIPELINE.md](SEARCH_PIPELINE.md) — retrieval、ranking、modes、limits。 +- [ARCHITECTURE_DEEP.md](ARCHITECTURE_DEEP.md) — 历史深入内容和可选细节。 +- [GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md) — provider 不可用时的行为。 +- [INSTALL.md](INSTALL.md) — 安装与修复命令。 +- [README.md](../../README.md) — 当前公开契约与测量质量。 diff --git a/docs/zh/ARCHITECTURE_DEEP.md b/docs/zh/ARCHITECTURE_DEEP.md index 33a5a5e7..a63afdb8 100644 --- a/docs/zh/ARCHITECTURE_DEEP.md +++ b/docs/zh/ARCHITECTURE_DEEP.md @@ -4,9 +4,13 @@ > **版本:** v3.5.0 | **最后更新:** 2026-08-12 +> **Phase 3 状态(2026-10-25):** 默认是文本优先(SQLite FTS5/BM25 + +> PropertyGraph);vector/LanceDB 和 phi-4 `ask` 模式可选。这是历史深入文档; +> 当前契约和边界见 [README.md](../../README.md)。 + ```mermaid flowchart TD - User[User / AI Agent] --> MCP[MCP Server\n61 tools] + User[User / AI Agent] --> MCP[MCP Server\n64 tools] MCP --> DI[DI Container\n15+ services] DI --> Search[Search Pipeline] DI --> Index[Indexing Pipeline] @@ -204,7 +208,7 @@ sequenceDiagram end DI-->>MCP: Container ready - MCP->>MCP: Register 61 tools + MCP->>MCP: Register 64 tools MCP-->>Zed: Server ready (PID announced) Note over Zed,DB: 总启动时间:~2-5秒(异步嵌入器初始化) diff --git a/docs/zh/CONTRIBUTING.md b/docs/zh/CONTRIBUTING.md index 4bd86c9f..74eaf974 100644 --- a/docs/zh/CONTRIBUTING.md +++ b/docs/zh/CONTRIBUTING.md @@ -15,7 +15,7 @@ pip install -r requirements.txt pip install -e "." ``` -要求:Python 3.10+,Windows(主要)或 Linux(实验性)。 +要求:Python 3.14+,Windows(主要)或 Linux(实验性)。 --- diff --git a/docs/zh/INSTALL.md b/docs/zh/INSTALL.md index de05d400..6cd89a2e 100644 --- a/docs/zh/INSTALL.md +++ b/docs/zh/INSTALL.md @@ -6,9 +6,13 @@ [🇬🇧 English](../en/INSTALL.md) • [🇷🇺 Русский](../ru/INSTALL.md) • [🇨🇳 中文](INSTALL.md) -> **MSCodebase Intelligence** — 用于 Zed IDE 中语义代码搜索的 MCP 服务器。 +> **MSCodebase Intelligence** — 用于 Zed IDE 的本地代码智能 MCP 服务器。 > 作为 Zed 扩展运行。开发目录:``。 +> **Phase 3 说明(2026-10-25):** 默认安装是文本优先 +> (SQLite FTS5/BM25 + PropertyGraph)。模型下载和 vector path 可选; +> LanceDB 属于 legacy/optional,不是默认检索存储。 + --- ## 🔧 系统要求 diff --git a/docs/zh/README.md b/docs/zh/README.md index 646b240f..749dca15 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -2,489 +2,321 @@ MSCodeBase Banner -[🇬🇧 English](../../README.md) • [🇷🇺 Русский](../ru/README.md) • [🇨🇳 中文](README.md) +[English](../../README.md) · [Русский](../ru/README.md) · [中文](README.md) # MSCodebase Intelligence -**Zed IDE 的 AI 驱动语义代码搜索 MCP 服务器 — 深度代码分析 MCP 服务器** +**面向 Zed 的本地优先代码智能:词法搜索、调用图、影响分析、项目记忆和安全写入工具,通过 MCP 提供。** -[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) -[![Tests](https://img.shields.io/badge/tests-1371%20passed-brightgreen)](../../tests/) +[![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[功能特性](#-功能特性) • [快速开始](#-快速开始) • [工具列表](#-mcp-工具共49个) • [文档地图](#-文档地图) • [安装指南](INSTALL.md) • [架构说明](ARCHITECTURE.md) • [贡献指南](../../CONTRIBUTING.md) • [安全策略](../../SECURITY.md) - -*最后更新:2026-08-03* +*更新于 2026-10-25。英文页面为准;本翻译可能滞后。* --- -## 🎯 定位 - -**MSCodeBase Intelligence** 是一个面向 **Zed IDE** 的 MCP 服务器,为 AI 助手提供 **对整个代码库的深度理解**:语义搜索、调用图、项目记忆、诊断。 - -这**不是** LSP 服务器,也不是编辑器内置自动补全的替代品。它是编辑器之上的"代码智能"层: +## 这是什么 -``` -┌─────────────────────────────────────────────────────┐ -│ Zed IDE │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ LSP(内置自动补全、 │ │ -│ │ 内联提示、诊断) │ │ -│ └───────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ MSCodeBase(MCP 服务器) │ │ -│ │ · 代码库语义搜索 │ │ -│ │ · 调用图与影响分析 │ │ -│ │ · 项目记忆(ADR、技术债务) │ │ -│ │ · 自诊断与自愈 │ │ -│ │ · 为 AI 助手提供 61 个工具 │ │ -│ └───────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ -``` +MSCodebase Intelligence 是一个**本地 MCP 服务器**,让 Zed 中的 AI 助手对代码库 +获得有依据的视图。它不是聊天模型,也不是编辑器 LSP 的替代品。它回答: -### 能力对比 - -| 功能 | MSCodeBase | 标准 LSP(pyright/pylsp) | -|---------|:----------:|:---------------------------:| -| 🔍 **语义搜索**(BM25 + 向量 + 重排序) | ✅ | ❌ | -| 🧠 **调用图 + 影响分析** | ✅ | ❌ | -| 🗃️ **项目记忆**(ADR、已知问题) | ✅ | ❌ | -| 🏥 **自诊断 + 自愈** | ✅ | ❌ | -| 🔎 **跨仓库搜索** | ✅ | ❌ | -| 🤖 **RAG 答案生成**(mode=ask) | ✅ | ❌ | -| 🔬 **搜索透明度**(per-stage 评分分解) | ✅ | ❌ | -| 🏛️ **架构漂移检测**(chain/hub/circular) | ✅ | ❌ | -| ✅ **声明验证**(AI 代理事实核查) | ✅ | ❌ | -| ✏️ **内联自动补全** | ❌ | ✅ | -| 🏷️ **内联提示** | ❌ | ✅ | - -### LSP:仅用于重命名(混合模式) - -MSCodeBase **在 `codebase(action="rename")` 中使用 LSP** — LSP 客户端(`src/core/lsp_client.py`)启动 **basedpyright** 以实现精确的跨文件重命名与 AST 分析(lsp_find_references / lsp_find_definition / lsp_document_symbols / lsp_get_type_info / lsp_get_diagnostics / lsp_get_code_actions),超时时自动回退到 SymbolIndex(Tree-sitter)。所有其他功能通过 **61 个 MCP 工具** 实现。 - -独立的 LSP 服务器(`src/lsp_main.py`)是实验性组件,**在 Zed 中无法工作** — 参见 [LSP_WONTFIX.md](investigations/LSP_WONTFIX.md)。 - -### 支持平台 - -在 **Windows** 上设计和测试。macOS 和 Linux 应可工作,但尚未经过官方验证。 - -### 支持语言 - -| 语言 | 解析 | 调用图 | Data Flow (ASSIGNED_FROM) | -|---|---|---|---| -| **Python** | ✅ | ✅ | ✅ | -| **TypeScript** | ✅ | ✅ | ✅ | -| **TSX** | ✅ | ✅ | ✅ | -| **Rust** | ✅ | ✅ | ✅ | -| **Go** | ✅ | ✅ | ✅ | -| **JavaScript** | ✅ | ✅ | ✅ | -| **Java** | ✅ | ✅ | ✅ | -| **C#** | ✅ | ✅ | ✅ | -| **Ruby** | ✅ | ✅ | ✅ | -| **PHP** | ✅ | ✅ | ✅ | -| **Kotlin** | ✅ | ✅ | ✅ | -| **Swift** | ✅ | ✅ | ✅ | -| **C** | ✅ | ✅ | ✅ | -| **C++** | ✅ | ✅ | ✅ | -| **Scala** | ✅ | ✅ | ✅ | -| **Dart** | ✅ | ✅ | ✅ | -| **Shell** | ✅ | ⚪ | ⚪ | -| **Bash** | ✅ | ⚪ | ⚪ | - -## ✨ 功能特性 - -| 功能 | 描述 | -|---------|-------------| -| 🔍 **统一搜索** | `search_code(query, mode, intent_hint)` — 单一工具:fast/quality/deep/context/ask/auto | -| 🧠 **智能层** | 16 个高级 `intel_*` 工具:自诊断、拓扑、记忆、错误预测 | -| 🗃️ **项目记忆** | ADR、已知问题、技术债务 — 跨会话自动持久化 | -| 🌐 **跨仓库搜索** | 使用 `@mention` 语法跨多个项目搜索 | -| 🌳 **调用图** | 完整调用图:定义 + 调用方 + 被调用方 + 影响分析 | -| 🏗 **结构搜索** | 13 种 AST 模式(class_inheritance、async_function、decorator 等) | -| 🔎 **上下文搜索** | 查找相似代码 — 粘贴片段,获取语义重复 | -| 🪣 **多桶 RAG** | 代码/文档桶,软权重,intent_hint(code/docs/auto) | -| 🤖 **mode=ask** | 通过 phi-4 生成 RAG 答案(server 配置) | -| 💾 **LanceDB v2** | 向量数据库,支持项目隔离(增量 BM25 重索引) | -| 🛡 **限流** | DebounceBatch + CircuitBreaker — 防止 VFS 循环 | -| 🏥 **自诊断** | `get_health_report` + `index_health` — 完整检查与恢复 | -| 🧪 **整洁架构** | DI 容器(18 个服务),62 个工具(29 core + 16 intel + 13 inline + 4 dev),1371 个测试 | -| 🪟 **多窗口** | `ProjectIndexerRegistry` — 每个项目独立 Indexer,LRU 5,ResourceMonitor 限流 | -| ✏️ **Write Tools** | `codebase(action=...)` — 统一枢纽:rename、move、delete、replace、insert、ack | -| ⚡ **Meta-Patching** | LanceDB `move_chunks_metadata` — 无需重新嵌入即可重命名 file_path(50ms vs 5s) | -| ⚙️ **SYSTEM_PROFILE** | `light`(同步)/ `server`(异步,带 phi-4) | -| 🔗 **数据流图** | `ASSIGNED_FROM` 边追踪变量赋值。Unified Walker + Conditional Flow(if/for/while/try)。MSCodeBase 上 29 种边类型。 | +- 符号在哪里定义?谁在调用?修改后会破坏什么? +- 哪些文件与问题相关——代码、测试还是文档? +- 项目已经做过哪些决定?哪些事故仍未关闭? +- rename/move/replace 能否安全预览并应用? ---- +当前默认是**文本优先检索**:SQLite FTS5/BM25 加 Tree-sitter/PropertyGraph 索引。 +Embedding 和 reranker 提供程序存在,但都是可选的,默认路径不需要它们。这是 +刻意选择:服务器离线仍可用,延迟可预测,向量数据库不会成为单点故障。 -## 🚀 快速开始 +## 当前可用的能力 -在 Zed 中安装 `mscodebase-intelligence` 扩展,然后: +| 领域 | 作用 | 备注 | +|---|---|---| +| 代码搜索 | 对解析后的 chunk 做 BM25/FTS5;符号/路径信号;可选图扩展 | `search_code` | +| 文档搜索 | 独立的 `docs/**` FTS 通道,BM25 分差足够大时自动启用 | 无 LLM 路由器 | +| 符号智能 | 定义、callers/callees、references、impact、structural search | `get_symbol_info`, `impact_analysis`, `structural_search` | +| 图 | 文件/符号 PropertyGraph:call、import、assignment、decorator、override | 用 `scripts/rebuild_graph.py` 重建 | +| 项目记忆 | ADR、事故、known issues、retract/supersede | `intel_*` | +| 安全写入 | rename、move、delete、replace、insert,带 preview/impact | `codebase(action=...)` | +| 诊断 | runtime passport、health、index status、logs、jobs | `debug_runtime_passport`, `get_health_report` | +| LSP rename | 基于 basedpyright 的跨文件 rename + SymbolIndex fallback | 仅 rename | -```bash -cd -python install.py +## 搜索质量:有测量,也有边界 -# 重启 Zed(File → Quit → 重新打开) -# 验证:intel_get_runtime_status() -``` +没有测量方法的数字没有意义。下面是内部的 **48 查询面板**(33 个真实项目问题 +加 15 个 docstring 派生问题),在干净索引上评估,排除 `experiments/**`,图存在。 +这不是通用 benchmark,也不代表所有语言或仓库。 -**install.py 完成以下操作:** -1. 将 39+ 个源文件复制到扩展目录 -2. 安装 Python 依赖 -3. 下载 llama-server.exe + GGUF 重排序模型(bge-reranker-v2-m3)。嵌入器(multilingual-e5-small INT8)为单独的 ONNX 模型 -4. 在 Zed 的 settings.json 中配置 MCP +| 路径 | hit@1 | hit@5 | 含义 | +|---|---:|---:|---| +| 代码,quality | 0.825 | 0.900 | 40 个代码问题中 33 个首位命中正确的文件 | +| 文档,精确路径 | 0.000 | 0.125 | Gold 指向 `docs/en/X`,检索常返回 `docs/ru/X` | +| 文档,同文件名 | 0.750 | 0.750 | 把翻译视为同一文档 | +| 总体 quality | 0.688 | 0.750 | 代码和文档按面板标注混合 | +| 总体 auto docs route | 0.792 | 0.875 | BM25-margin 路由 + 文档通道;翻译已归一化 | +| Fast | 0.542 | 0.729 | 质量较低、延迟较低;用于精确/简单查找 | -另见:[AI_INSTALLATION_PROMPT.md](../../AI_INSTALLATION_PROMPT.md)、[INSTALL.md](INSTALL.md) +这些数字**不**代表: -### 提供者 +- **不是 95% 准确率。** 该面板上没有任何静态方案达到这个水平。双通道 repo map + (代码 PageRank + 文档 BM25)在 1k/2k/4k token 下、理想路由时约 40%/62%/73% + gold 文件覆盖率;单独 PageRank 在代码查询上约 33%/55%/68%。它是上下文选择 + 工具,不是搜索替代品。 +- **不是所有本地 reranker 都有帮助。** 多个模型被测量后拒绝:它们降低 hit@1 + 或增加不可接受的延迟。 +- **不是文档通道坏了。** 它经常返回另一种语言的正确文档(`docs/ru/X` 而不是 + `docs/en/X`)。这是 gold 标注问题,不是检索失败。 -MCP 自动选择最佳可用提供者: +面板、原始运行、假设和 summary 位于 `experiments/`。它是可复现的实验记录, +不是营销文案。 -``` -ONNX/OpenVINO INT8(进程内)→ llama.cpp GGUF(GPU)→ LM Studio(如果运行中)→ 仅 BM25 - ~0.5 GB RAM ~1.7 GB RAM (2× llama-server) ~6 GB RAM 无嵌入 - e5-small 嵌入器(384dim) 重排序器(bge-reranker-v2-m3) 外部 API -``` +## 搜索如何工作 -基准测试:[docs/research/2026-07-10-final-benchmark.md](../research/2026-07-10-final-benchmark.md) +默认路径是确定性的: ---- +1. **Parse/index** — 文件 → chunk + PropertyGraph。 +2. **Retrieve** — SQLite FTS5/BM25、符号/路径信号,可选图邻居。 +3. **Rank/clean** — 可选 on-demand rerank、exact-name boost 和符号去重。 + RRF/MMR/dense fusion 属于可选 LanceDB/vector 路径,不是每个默认查询都会执行。 +4. **Docs routing** — 最佳 docs 分数超过最佳 code 分数达到可配置 margin + (`MSCODEBASE_DOCS_MARGIN`,默认 `4.0`)时走文档通道。 +5. **Return** — 带 metadata 的 chunk/文件;`intent="docs"` 或 + `intent="code"` 可覆盖自动选择。 -## 📚 文档地图 - -| 文档 | 描述 | 受众 | 语言 | -|----------|-------------|----------|-----------| -| **[INSTALL.md](INSTALL.md)** | 安装、设置、卸载 | 用户 | 🇬🇧 🇷🇺 🇨🇳 | -| **[ARCHITECTURE.md](ARCHITECTURE.md)** | 整洁架构、层次、DI | 开发者 | 🇬🇧 🇷🇺 🇨🇳 | -| **[ARCHITECTURE_DEEP.md](ARCHITECTURE_DEEP.md)** | 深度架构:流水线、生命周期、对比 | 架构师 | 🇬🇧 🇷🇺 🇨🇳 | -| **[SEARCH_PIPELINE.md](SEARCH_PIPELINE.md)** | 搜索流水线:BM25 → RRF → 重排序 | 开发者 | 🇬🇧 | -| **[GRACEFUL_DEGRADATION.md](GRACEFUL_DEGRADATION.md)** | 5 级优雅降级(llama.cpp → ONNX → BM25) | DevOps | 🇬🇧 | -| **[ARCHITECTURE_LAYERS.md](ARCHITECTURE_LAYERS.md)** | 10 个运行时层次 | 架构师 | 🇬🇧 🇷🇺 🇨🇳 | -| **[FAQ.md](FAQ.md)** | 常见问题 | 全部 | 🇬🇧 🇷🇺 🇨🇳 | -| **[TELEMETRY.md](TELEMETRY.md)** | 指标、ETA、数据收集 | DevOps | 🇬🇧 🇷🇺 🇨🇳 | -| **[investigations/ONNX_SESSION_REPORT.md](../en/investigations/ONNX_SESSION_REPORT.md)** | 完整 ONNX 迁移、7 个修复、基准测试 | 支持 | 🇬🇧 | -| **[investigations/LSP_WONTFIX.md](investigations/LSP_WONTFIX.md)** | Windows 上 LSP 调研(WONTFIX) | 支持 | 🇬🇧 🇨🇳 | -| **[ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md)** | Windows 特性、受限模式 | Windows 用户 | 🇬🇧 🇷🇺 🇨🇳 | -| **[CHANGELOG.md](CHANGELOG.md)** | 版本历史 | 全部 | 🇬🇧 🇷🇺 🇨🇳 | -| **[CONTRIBUTING.md](CONTRIBUTING.md)** | 如何贡献、PR | 贡献者 | 🇬🇧 🇷🇺 🇨🇳 | -| **[SECURITY.md](SECURITY.md)** | 安全策略、漏洞 | 安全人员 | 🇬🇧 🇷🇺 🇨🇳 | -| **[../../AGENTS.md](../../AGENTS.md)** | AI 代理系统规则 | AI 代理 | 🇬🇧 | -| **[../../SECURITY.md](../../SECURITY.md)** | 安全策略、报告漏洞 | 安全人员 | 🇬🇧 | -| **[../../CODE_OF_CONDUCT.md](../../CODE_OF_CONDUCT.md)** | 社区准则 | 贡献者 | 🇬🇧 | - -| **[../../KNOWN_ISSUES.md](../../KNOWN_ISSUES.md)** | 已知问题与技术债务注册表 | 全部 | 🇬🇧 | - -所有文档相互引用。提供 3 种语言:English、Русский、中文。 +路由中没有 LLM。它只是一次额外 FTS 查询和分数比较:本地、可解释、成本低。 ---- +可选层: -## 🔧 MCP 工具(共 61 个) - -### 核心搜索 - -| 工具 | 使用场景 | -|------|-------------| -| `search_code(query, mode, filter_layer, intent_hint)` | **主搜索工具。** `mode="auto"` / `"fast"` / `"quality"` / `"deep"` / `"context"` / `"ask"`。`intent_hint="code"` / `"docs"` / `"auto"` — 软桶权重。`filter_layer="core"` — 在特定架构层内搜索 | -| `structural_search(pattern)` | AST 搜索:`class_inheritance`、`async_function`、`function_with_decorator` 等 | -| `cross_repo_search(query @repo)` | 跨多项目搜索(单体仓库) | -| `cross_project_deps(action)` | 跨项目依赖图:`graph` / `deps` / `cycles` / `impact` | -| `get_symbol_info(query)` | 调用图:调用方、被调用方、影响文件 | -| `execute_script(code, timeout, args)` | **沙箱 Python 执行(3 层)。** AST 验证 + runtime `__import__` 包装 + subprocess 隔离。审计日志。返回 `{stdout, stderr, exit_code, duration_ms, truncated, timed_out}` | -| `impact_analysis(symbol)` | 符号变更影响分析(风险分数、深度) | - -### 索引管理(通过 `codebase(action="index", ...)`) - -| 操作 | 使用场景 | -|------|-------------| -| `codebase(action="index", path="status")` | 索引状态:块数、文件数、符号数(`get_index_status`) | -| `codebase(action="index", path="progress")` | 索引进度(阶段、百分比) | -| `codebase(action="index", path="project_dir", project_root=...)` | 开始完整项目索引(`index_project_dir`) | -| `codebase(action="index", path="timeline")` | 按日期查看索引历史 | -| `codebase(action="index", path="health")` | 索引诊断与自我恢复(`index_health`) | -| `notify_change(file_path)` | 强制更新某个文件的索引(通过 DebounceBatch)— inline 工具 | -| `generate_chunk_summaries(root)` | 代码块的 LLM 生成描述 | -| `scan_changes(project_root)` | 架构差异 — 分析自上次基线以来的变更 | - -### 系统与诊断 - -| 工具 | 使用场景 | -|------|-------------| -| `get_health_report()` | **完整自诊断:** 索引、嵌入器、日志、同步 | -| `get_logs(project_root)` | 项目日志中的最新错误和警告 | -| `read_live_file(path)` | 从 LSP 内存读取文件(含未保存的更改) | - -### 分析 - -| 工具 | 使用场景 | -|------|-------------| -| `get_hotspots(project_root)` | 热点 — 高缺陷率的文件 | -| `get_repo_rank(project_root, top_k)` | 符号重要性排名(调用图上的 PageRank) | -| `get_bug_correlation(project_root)` | 缺陷-变更关联分析 | -| `get_repo_map(project_root)` | 项目地图:文件树 + 关键符号 | -| `graph_query(action="related", target=path)` | 通过共同变更/缺陷关联相关的文件(通过 `related` 操作) | -| `graph_query(action, target)` | 图查询:`impact` / `feature` / `deps` / `tests` / `cypher` / `flow` / `drift` / `verify` | -| `find_similar_bugs(error)` | 通过错误文本从历史中查找类似缺陷 | - -### Git 与历史(通过 `codebase(action="git", ...)`) - -| 操作 | 使用场景 | -|------|-------------| -| `codebase(action="git", path="log", ...)` | 语义化提交历史(`get_commit_history`) | -| `codebase(action="git", path="history", ...)` | 特定文件的变更历史 | -| `codebase(action="git", path="branch")` | 分支信息 + 索引状态(`get_branch_info`) | - -### 生命周期与验证 - -| 工具 | 使用场景 | -|------|-------------| -| `submit_background_task(type, root)` | 运行长任务:`bug_correlation` / `build_knowledge_graph` / `full_analysis` | -| `get_task_status(task_id)` | 后台任务状态 | -| `verify_action(action_type)` | 验证:`file_write` / `git_commit` / `git_push` / `index_sync` | - -### Write Tools — `codebase(action=...)` - -| 操作 | 使用场景 | -|------|-------------| -| `codebase(action="rename", old, new, apply)` | 在所有文件中重命名符号(预览/应用,冲突检查) | -| `codebase(action="move", symbol, to_file, apply)` | 将符号移动到另一个文件(预览/应用,导入更新) | -| `codebase(action="safe_delete", symbol, force, apply)` | 安全删除并检查引用(强制模式) | -| `codebase(action="replace", symbol, new_code, apply)` | 替换函数/类主体(预览/应用) | -| `codebase(action="insert_before", anchor, new_code, apply)` | 在锚点符号前插入代码(预览/应用) | -| `codebase(action="insert_after", anchor, new_code, apply)` | 在锚点主体后插入代码(预览/应用) | -| `codebase(action="ack_impact", file_path)` | 确认影响以解除 modification guard | - -### 智能层(intel_*)— 16 个高级工具 - -| 工具 | 功能 | -|------|-------------| -| `intel_get_runtime_status()` | 聚合健康状态:嵌入器、索引、资源使用 | -| `intel_trigger_reindex()` | 即发即弃的重索引(不阻塞 Zed) | -| `intel_get_job_status(job_id)` | 后台任务进度 | -| `intel_code_topology(symbol)` | 调用图 + 模块拓扑(< 2 秒) | -| `intel_get_project_memory()` | 项目记忆地图:ADR、known_issues、tech_debt | -| `intel_log_incident(...)` | 记录事件到项目历史 | -| `intel_analyze_incident(error)` | 查找类似事件 + 现成解决方案 | -| `intel_add_memory_node(section, data)` | 添加记录到项目记忆 | -| `intel_get_hotspots()` | 缺陷负载最高的 Top-5 文件 | -| `intel_predict_root_cause(error)` | 从日志 + 历史预测根本原因 | -| `intel_get_telemetry(days)` | 按工具统计的遥测、资源使用、LLM 统计 | -| `intel_auto_collect_adrs(max_commits)` | 从提交历史自动生成 ADR | -| `intel_reset_index()` | 删除并从头重建索引 | -| `intel_retract_memory_node(node_id, reason)` | 撤回记忆节点(ACTIVE/VERIFIED → REFUTED,必须提供原因) | -| `intel_restore_memory_node(node_id, reason)` | 从 REFUTED 恢复节点(手动返回,ADR-0002/0003) | -| `intel_supersede_memory_node(node_id, reason, new_node_id)` | 将节点标记为 SUPERSEDED — 被更新的事实取代 | - -> `intel_tool_health()`、`intel_explain_project_state()`、`intel_get_project_context()` — 见下方诊断工具。 - -### 开发工具(4 个) - -| 工具 | 功能 | -|------|-------------| -| `generate_docs(project_root)` | 从 PropertyGraph 生成 Markdown 文档(DEPRECATED — 使用 auto_update_docs) | -| `bump_version(project_root, part, dry_run)` | 提升项目版本 + 更新 CHANGELOG | -| `auto_update_docs(project_root, action)` | 自动更新文档:update/check | -| `install_git_hooks(project_root, action)` | 安装 pre-commit 钩子:install/uninstall/status | - -### 诊断工具(7 个) - -| 工具 | 功能 | -|------|-------------| -| `debug_runtime_passport()` | 进程护照:RUN_ID、PID、构建信息 | -| `get_runtime_counters()` | 运行时计数器:调用次数、阻塞次数、警告次数 | -| `intel_execution_timeline(limit)` | 最近操作时间线及耗时 | -| `intel_get_project_context(root)` | 单一快照:状态、索引、健康、记忆 | -| `intel_explain_project_state(root)` | 人类可读的项目状态诊断 | -| `intel_tool_health()` | 工具成功率、延迟、置信度 | -| `refresh_db_connection()` | 重置数据库句柄并重新连接 | +- Embedding 提供程序(llama.cpp GGUF、ONNX、LM Studio/Ollama)提供 vector + candidates。 +- 配置提供程序时的 on-demand rerank。 +- Graph expansion 和 PageRank 风格 repo ranking。 ---- +当 `MSCODEBASE_DENSE_OFF=true`(当前默认)时,代码/文档查询不依赖 embedding +server。 -## 🏗️ 架构 +## 快速开始 -### 基于 DI 容器的整洁架构 +要求:Windows 是已验证平台;Python 3.14+;Zed 安装 +`mscodebase-intelligence` 扩展。 +```bash +cd +python install.py +# 仅同步代码,不提问: +python install.py --sync +# CI 模式,不提问: +python install.py --yes ``` -┌──────────────────────────────────────────────────────────────────┐ -│ MCP 服务器(约 600 行) │ -│ src/mcp/server.py + server_tools.py + server_factory.py │ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ DI 容器(18 个服务) │ │ -│ │ src/core/di_container.py — ServiceCollection │ │ -│ │ │ │ -│ │ ┌──────────┐ ┌────────────┐ ┌──────────────────────┐ │ │ -│ │ │ Indexer │ │ Searcher │ │ DebounceBatch │ │ │ -│ │ │ Embedder │ │ SymbolIdx │ │ CircuitBreaker │ │ │ -│ │ │ Parser │ │ FileGuard │ │ RateLimiter │ │ │ -│ │ └──────────┘ └────────────┘ └──────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────┴────────────┐ │ -│ ▼ ▼ │ -│ ┌────────────────────┐ ┌────────────────────────────────────┐ │ -│ │ 28 个工具类 │ │ 16 intel_* + 13 inline 工具 │ │ -│ │ src/mcp/tools/*.py│ │ intelligence/layer.py + │ │ -│ │ + codebase hub │ │ server_tools.py (inline) │ │ -│ │ 构造函数注入 │ │ error_boundary 装饰器 │ │ -│ │ 1 execute_script │ │ asyncio.wait_for(timeout) │ │ -│ └────────────────────┘ └────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────┐ ┌───────────────────┐ -│ RemoteEmbedder │ │ LanceDB v2 │ -│ (LM Studio / │ │ (向量数据库) │ -│ Ollama / ONNX) │ │ BM25 + 向量 │ -└─────────────────┘ └───────────────────┘ -``` - ---- -## ⚡ 性能 +`install.py` 会把服务器复制到 Zed 扩展目录、安装依赖、可选准备模型,并写入 +MCP 配置。完成后重启 Zed。 -| 模式 | 延迟 | 最佳用途 | -|:-----|:--------|:---------| -| `search_code(query, mode="fast")` | ~80-500ms | 简单关键词/精确名称 | -| `search_code(query, mode="quality")` | ~250-2000ms | 带重排序的语义搜索 | -| `search_code(query, mode="deep")` | ~2-5s | 跨模块复杂调研 | -| `search_code(query, mode="context")` | ~200-800ms | 通过片段查找相似代码 | -| `get_symbol_info(query)` | ~200-1500ms | 符号定义 + 调用图 | -| `impact_analysis(symbol)` | ~1-5s | 变更影响分析 | +在 agent panel 中验证: -### 环境变量 - -| 变量 | 默认值 | 描述 | -|----------|---------|-------------| -| `LM_STUDIO_URL` | `http://localhost:1234/v1` | LM Studio API 端点 | -| `LM_STUDIO_PORT` | `1234` | LM Studio 端口 | -| `OLLAMA_URL` | `http://localhost:11434` | Ollama API 端点 | -| `LOG_LEVEL` | `INFO` | 日志详细级别 | -| `ZED_WINDOWS_QUIRKS.md` | *(见文件)* | Windows 特定说明 | - ---- - -## 🔧 故障排除 - -### MCP 服务器无响应 - -**症状:** 工具超时,无响应。 - -**检查清单:** -1. **File → Quit** → 重新打开项目 -2. 运行 `python install.py` 重新配置 -3. 检查日志:`%LOCALAPPDATA%\mscodebase\logs\`(data_root) - -### 索引为空(0 个块) - -在代理面板中运行: +```text +debug_runtime_passport() +intel_get_runtime_status() +codebase(action="index", path="status") ``` -intel_trigger_reindex() -``` - -然后验证:`get_index_status()` -### LM Studio 连接问题 +如果图为空但 chunk 存在,只重建图(不计算 vector): ```bash -# 验证服务器是否响应: -python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:1234/v1/health').read())" +python scripts/rebuild_graph.py ``` -预期输出:`{"status":"ok"}`。 - ---- - -## 📁 项目结构 +如果索引过期或缺失,重建文本 chunk 和图: +```bash +python scripts/reindex_text_only.py --project . ``` -mscodebase-intelligence/ -├── src/ -│ ├── main.py # MCP 服务器入口点(约 194 行) -│ ├── lsp_main.py # LSP 服务器(基于 DI,用于 didSave 索引) -│ ├── mcp/ -│ │ ├── server.py # MCP 服务器创建(约 597 行) -│ │ ├── server_factory.py # DI 设置 + 服务器生命周期 -│ │ ├── server_tools.py # 工具注册 + 12 个 inline 工具(约 607 行) -│ │ └── tools/ # 16 个模块 + base 类 -│ │ ├── codebase_tool.py # codebase(action=...) hub + execute_script -│ │ ├── search_tools.py # search_code、get_symbol_info、impact_analysis -│ │ ├── indexing_tools.py # notify_change、index_project_dir、index_health -│ │ ├── git_tools.py # get_branch_info、get_commit_history、get_file_history -│ │ ├── system_tools.py # get_index_status、get_health_report、read_live_file、get_logs -│ │ ├── analysis_tools.py # structural_search、get_repo_map、get_repo_rank、scan_changes -│ │ ├── graph_tools.py # cross_repo_search、cross_project_deps、graph_query -│ │ ├── investigation_tools.py # get_bug_correlation、get_hotspots、find_similar_bugs -│ │ ├── lifecycle_tools.py # submit_background_task、get_task_status、verify_action -│ │ ├── meta_tools.py # IndexTool、GitTool、SystemTool(codebase hub 的 spoke) -│ │ └── write_tools.py # WriteTool(rename、move、delete、replace、insert) -│ ├── core/ -│ │ ├── di_container.py # ★ DI 容器(18 个服务,ServiceCollection) -│ │ ├── error_handler.py # ★ error_boundary + ToolError -│ │ ├── rate_limiter.py # ★ SlidingWindowRateLimiter + DebounceBatch + CircuitBreaker -│ │ ├── indexer.py # LanceDB 向量存储 -│ │ ├── searcher.py # 混合搜索(BM25 + 密集向量 + RRF) -│ │ ├── symbol_index.py # 调用图(BFS、影响分析) -│ │ ├── intelligence_layer.py # intel_* 工具(14 个高级) -│ │ ├── llama_runner.py # llama.cpp 生命周期管理器 ★ -│ ├── remote_embedder.py # ONNX/OpenVINO multilingual-e5-small INT8(进程内)+ LM Studio / Ollama fallback -│ │ ├── reranker.py # 多提供者重排序(HTTP 到提供者) -│ │ ├── parser.py # Tree-sitter AST -│ │ ├── health_report.py # 自诊断引擎 -│ │ └── ... -│ └── utils/ -│ ├── paths.py # SafePathManager、to_win_long_path -│ └── zed_config.py # 自动配置 Zed 设置 -├── docs/ -│ ├── en/ # 英文文档 -│ ├── ru/ # 俄文文档 -│ └── zh/ # 中文文档 -├── tests/ # 853 个测试(pytest) -├── .agents/skills/ # AI 代理技能 -├── install.py # 安装程序 -└── README.md -``` - ---- -## 🛠️ 开发 +然后重启 MCP/Zed,让新进程打开重建后的存储。 + +## 工具 + +服务器注册 **64 个工具**,分为四组;启用 +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true` 时增加一个可选的 +`execute_script`。默认 allowlist 暴露 **49 个**;隐藏工具可通过 +`MSCODEBASE_MCP_TOOLS` 启用(空字符串显示全部)。准确列表见 MCP 启动日志和 +`intel_get_runtime_status`。 + +| 组 | 示例 | 用途 | +|---|---|---| +| 搜索与分析 | `search_code`, `plan_search`, `get_symbol_info`, `impact_analysis`, `structural_search`, `graph_query` | 代码、符号、关系、impact | +| 索引与系统 | `codebase(action="index"...)`, `notify_change`, `read_live_file`, `get_health_report`, `get_logs` | 索引新鲜度和诊断 | +| Intelligence | `intel_get_runtime_status`, `intel_get_project_memory`, `intel_log_incident`, `intel_analyze_incident`, `intel_predict_root_cause` | 记忆、事故、runtime | +| 写入与生命周期 | `codebase(action="rename"...)`, `move`, `safe_delete`, `replace`, `insert_before/after`, `ack_impact` | 预览/应用安全修改 | +| Dev | `bump_version`, `auto_update_docs`, `install_git_hooks`, `generate_docs` | 仓库维护 | + +`codebase` 是 hub:`action` 选择 index、git 或 write。请查看 MCP 客户端中的 +tool schema,不要依赖可能过时的手写列表。 + +## 架构 + +```text +Zed + │ + ├── built-in LSP (autocomplete, diagnostics) + │ + └── MCP client + │ + ▼ + MSCodebase MCP server + ├── Search SQLite FTS5/BM25, symbol/path signals, optional graph + ├── Graph PropertyGraph: calls, imports, assignments, decorators, overrides + ├── Index parser → chunks → FTS5 + graph; freshness checks + ├── Memory ADRs, incidents, known issues, retract/supersede + ├── Writes rename/move/delete/replace/insert with impact guards + ├── Diagnostics runtime passport, health, jobs, logs, telemetry + └── Optional embedding providers, reranker, LSP rename +``` -参见 [CONTRIBUTING.md](CONTRIBUTING.md) 了解: -- 如何添加新的 MCP 工具 -- 测试结构与 CI 流水线 -- 提交信息约定 +规范数据位置在仓库之外: + +- `%LOCALAPPDATA%/mscodebase/projects//` — 每项目 graph、memory、jobs。 +- `%LOCALAPPDATA%/mscodebase/logs/` — 服务器日志。 +- `/.mscodebase/chunks.db` — Phase 3 文本 chunk store。 + +LanceDB 仍属于 legacy/可选 vector path;默认 text-first 模式不需要它。 + +## 配置 + +所有设置都是 environment variables,defaults 在 `src/config/settings.py`。搜索 +相关重点: + +| 变量 | 默认 | 作用 | +|---|---|---| +| `MSCODEBASE_DENSE_OFF` | `true` | 跳过 dense/vector retrieval;使用 FTS5、symbols、graph | +| `MSCODEBASE_SQLITE_CHUNKS` | `true` | 使用 SQLite/FTS5 chunk store | +| `MSCODEBASE_DEMOTE_NON_CODE` | `false` | 在 fused pool 中降低 tests/docs/experiments | +| `MSCODEBASE_DEMOTE_INTENT_AWARE` | `false` | docs intent 时不降低 docs | +| `MSCODEBASE_DOCS_MARGIN` | `4.0` | 自动文档路由阈值(docs − code score) | +| `MSCODEBASE_ONDEMAND_RERANK` | `true` | 可选 live rerank;需要 provider | +| `MSCODEBASE_P3_ANCHOR` | `false` | module-head anchors;无 reranker 时增加延迟 | +| `MSCODEBASE_MCP_TOOLS` | default set | 工具 allowlist;空 = 全部 | + +`experiments/**` 被有意排除在索引之外。那里是 frozen 面板和运行产物;索引它们 +曾让 BM25 返回面板本身而不是代码。文件仍保留在 git 中。 + +## 支持的语言 + +Python 是主要且完整验证的语言。Tree-sitter 解析覆盖 JavaScript、 +TypeScript/TSX、Rust、Go、Java、C#、Ruby、PHP、Kotlin、Swift、C、C++、Scala +和 Dart。SQL、YAML、TOML、HTML、CSS、HCL/Terraform 作为上下文索引,不保证 +同等 call graph 覆盖。图和 data-flow 深度取决于 grammar;并非所有语言都有 +相同边类型。 + +## 限制与已知问题 + +这些是有意记录、公开的边界,不是隐藏行为: + +- **Docs gold 与语言:** 文档通道可能返回 `docs/ru/X.md`,而 gold 是 + `docs/en/X.md`。文档是正确的,精确路径指标不是。评估翻译时按 basename 归一化。 +- **自动文档路由是启发式:** margin 可配置,并在 48 查询面板上调过。带有强 + 文档词的代码查询仍可能被路由到 docs;`intent="code"` 是退路。 +- **Graph rebuild 与 text reindex 分离:** `MSCODEBASE_DENSE_OFF=true` 时 + 完整 vector reindex 被有意阻止。图用 `scripts/rebuild_graph.py`,chunk 用 + `scripts/reindex_text_only.py`。 +- **不宣称 95%:** 静态检索和测试过的本地 reranker 在该内部面板上都没有达到。 + LLM planner/agent loop 是另一个实验,不是默认路径。 +- **Fast mode 不是 quality mode:** 更快,但在混合自然语言问题上明显更不准确。 + 用于精确名称和已知路径。 +- **可见工具受 allowlist 控制:** 源码里的“64 tools”不代表某个客户端能看到 + 全部 64 个;检查 `MSCODEBASE_MCP_TOOLS`。 + +## 报告问题 + +有用的报告是一个小实验,而不只是一个数字。请包含: + +1. 准确的 tool call 和参数(`query`、`mode`、`intent`、`limit`)。 +2. `debug_runtime_passport()` 和 `codebase(action="index", path="status")`。 +3. 是否重建过图(`scripts/rebuild_graph.py`),以及是否启用 + `MSCODEBASE_DENSE_OFF` / `MSCODEBASE_ONDEMAND_RERANK`。 +4. 期望文件与实际返回文件;是精确路径还是同文件名(翻译算同一文档)。 +5. 可复现的最小查询,以及 `%LOCALAPPDATA%/mscodebase/logs/` 的相关日志。 + +这样可以把四种看起来相似的问题分开:排序差、候选缺失、索引过期/为空、 +gold 标注不匹配。 + +## 项目结构 + +```text +src/ + main.py MCP entry point + mcp/ + server.py server creation + server_factory.py DI and lifecycle + server_tools.py tool registration and inline tools + tools/ search, index, graph, memory, write, lifecycle tools + core/ + search/ engine, FTS5/BM25, scoring, query plan, graph adapter + indexing/ parser, chunk store, index pipeline, file guard + intelligence/ project memory, incidents, health, jobs + graph.py PropertyGraph + di_container.py service collection + config/settings.py environment-backed settings + providers/ optional embedders and rerankers +experiments/ frozen panels, run artifacts, summaries +scripts/ install/reindex/graph/smoke utilities +tests/ pytest suite +docs/ architecture, installation, operations, research +``` -### 开发者快速开始 +## 开发 ```bash -# 设置 python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt -# 直接运行 MCP 服务器(测试) -python -m src.main +# 聚焦搜索测试 +pytest tests/test_search_docs.py tests/test_query_plan.py \ + tests/test_experiment_output_guard.py tests/test_searcher.py -q -# 运行测试 +# 完整测试套件(integration/benchmark 由 marker 选择) pytest tests/ -m "not integration and not benchmark" + +# Live smoke:真实服务,无 mock +python scripts/smoke_e2e.py --project . ``` ---- +仓库使用 Conventional Commits,实验输入保存在 `experiments/**/frozen/`,结果记录 +在 `EXPERIMENTS_LOG.md`、`AGENT_DIARY.md` 和 `KNOWN_ISSUES.md`。 -## 📄 许可证 +## 文档地图 -MIT 许可证 — 详见 [LICENSE](../../LICENSE)。 +| 文档 | 内容 | +|---|---| +| [docs/en/INSTALL.md](../en/INSTALL.md) | 安装、卸载、平台说明 | +| [docs/en/ARCHITECTURE.md](../en/ARCHITECTURE.md) | 分层、DI、模块边界 | +| [docs/en/ARCHITECTURE_DEEP.md](../en/ARCHITECTURE_DEEP.md) | Pipeline 与 lifecycle | +| [docs/en/SEARCH_PIPELINE.md](../en/SEARCH_PIPELINE.md) | Retrieval、fusion、scoring、rerank | +| [docs/en/GRACEFUL_DEGRADATION.md](../en/GRACEFUL_DEGRADATION.md) | provider 不可用时的行为 | +| [docs/en/ZED_WINDOWS_QUIRKS.md](../en/ZED_WINDOWS_QUIRKS.md) | Windows 与 Zed | +| [docs/en/CHANGELOG.md](../en/CHANGELOG.md) | 版本历史 | +| [AGENTS.md](../../AGENTS.md) | AI agent 规则 | +| [KNOWN_ISSUES.md](../../KNOWN_ISSUES.md) | 开放问题与技术债 | +| [EXPERIMENTS_LOG.md](../../EXPERIMENTS_LOG.md) | 实验假设与原始结果 | ---- +## Research notes + +- [PageRank vs RAG on a real codebase](https://dev.to/mansio/i-measured-pagerank-token-savings-on-a-real-codebase-the-result-will-surprise-you-5bnj) +- [Fact-checking one AI's audit with another](https://dev.to/mansio/i-asked-one-ai-to-fact-check-another-ais-audit-of-my-own-code-1ac3) +- [The silent vector contamination bug](https://dev.to/mansio/the-silent-vector-contamination-bug-why-your-concurrent-embeddings-might-be-lying-to-you-5fg7) + +## 许可证 + +MIT。见 [LICENSE](../../LICENSE)。 -## 🙏 致谢 +## 致谢 -- [Zed IDE](https://zed.dev/) — 代码编辑器 -- [LM Studio](https://lmstudio.ai/) — 本地 LLM 推理 -- [LanceDB](https://lancedb.github.io/) — 向量数据库 -- [Model Context Protocol](https://modelcontextprotocol.io/) — MCP 标准 \ No newline at end of file +Zed、Model Context Protocol、Tree-sitter、SQLite/FTS5,以及让离线优先助手 +变得实用的本地推理项目。 diff --git a/docs/zh/SEARCH_PIPELINE.md b/docs/zh/SEARCH_PIPELINE.md index 5936f308..08626227 100644 --- a/docs/zh/SEARCH_PIPELINE.md +++ b/docs/zh/SEARCH_PIPELINE.md @@ -1,295 +1,211 @@ -# 搜索流水线(pipeline)— 完整技术参考 - -> **MSCodeBase Intelligence 的一部分** | v3.5.0 - -## 概述 - -搜索流水线(pipeline)是 MSCodeBase 的核心。它结合了 **4 个检索阶段 + MultiSignalScorer** 来查找最相关的代码上下文。 - -```mermaid -flowchart TD - Q[User Query] --> PRE[Preprocessing] - PRE --> INT{Intent Detection} - INT -->|code intent| CW[Code-optimized weights] - INT -->|docs intent| DW[Docs-optimized weights] - INT -->|auto| BW[Balanced weights] - - CW --> PAR[Parallel Search] - DW --> PAR - BW --> PAR - - subgraph PAR[Parallel Search — 2 channels] - BM25[BM25 Sparse\nkeyword match] - DENSE[LanceDB Dense\nsemantic search] - end - - BM25 --> RRF[RRF Fusion\nk=60] - DENSE --> RRF - - RRF --> MULTI[MultiSignalScorer v3.0] - MULTI --> BUCKET[Multi-Bucket RAG] - - subgraph MULTI[MultiSignalScorer — 4 signals] - API[api_signature\nJaccard symbol match] - DIFF[graph_diffusion\nPageRank centrality] - MOD[module_proximity\nhierarchy closeness] - CO2[cochange_boost\ngit coupling] - end - - BUCKET --> GRAPH[Graph expand\ncallees from AST] - GRAPH --> RERANK[Cross-encoder\nbge-reranker-v2-m3] - - RERANK --> CUT[Cut to limit] - CUT --> FMT[Format response] - FMT --> RES[Final Results] +# 搜索流水线 — 当前契约 + +> **更新于 2026-10-25。** 默认运行时是 Phase 3 text-first:SQLite FTS5/BM25 + +> PropertyGraph。Dense、LanceDB 和 reranker 阶段可选,未配置时关闭。本文描述 +> 默认路径的真实行为,而不是把旧 pipeline 当作默认。 + +--- + +## 1. 契约 + +输入: + +- natural-language 或 identifier query; +- `mode`:`fast`、`quality`、`deep`、`context`、`ask`、`auto`; +- 可选 `layer`、`intent`、`limit`、`explain`。 + +输出: + +- 带 metadata 的 ranked chunks/files(`file`、`chunk_index`、`symbol_name`、 + `symbol_kind`、`layer`、line range、source); +- freshness check 失败时的 stale-index banner; +- 不可信索引内容的安全 metadata(可选)。 + +Agent 可传 `intent="docs"` 或 `intent="code"`。`intent` 为空时,tool 比较最佳 +docs-only BM25 score 与最佳 code score;差值超过 +`MSCODEBASE_DOCS_MARGIN`(默认 `4.0`)时走 docs 通道。此决策没有 LLM。 + +## 2. 默认 Phase 3 路径 + +```text +query + │ + ├─ normalize / optional RU synonyms + │ + ├─ SQLiteChunkStore.search(query, limit=20..50) + │ └─ FTS5/BM25 over chunk text, symbol_name, docstring, file_path + │ + ├─ 如果 query 像 identifier/path: + │ └─ PropertyGraph stage 添加 exact symbol/file candidates + │ + ├─ optional on-demand rerank(仅配置时) + │ + ├─ exact-name boost + ├─ symbol deduplication + │ + └─ return top-N ``` -## 阶段详情 - -### 1. 查询扩展 - -```python -_EXPANSION_SYNONYMS = { - "auth": ["authentication", "login", "authorize"], - "error": ["exception", "failure", "bug"], - "create": ["add", "insert", "new"], - # ... 8 more groups -} - -def expand_query(query: str, max_expansions: int = 3) -> list[str]: - """Generate synonym variants. Each variant is searched independently.""" - variants = [query] - words = query.lower().split() - for word in words: - synonyms = _EXPANSION_SYNONYMS.get(word, []) - for syn in synonyms[:max_expansions - 1]: - variant = query.replace(word, syn, 1) - if variant not in variants: - variants.append(variant) - return variants -``` +SQLite 路径刻意比历史 LanceDB pipeline 更短。默认**不会**运行 BM25+dense+graph +的 RRF、MMR、co-change boosts 或 P3 module-head anchors。这些阶段属于可选 +dense/LanceDB 路径或旧代码路径。 -### 2. BM25 搜索(稀疏) - -- **目的:** 精确关键词匹配 — 查找包含特定术语的代码 -- **索引:** 增量构建,基于 LanceDB 块(chunk),存储为 `Dict[doc_id, Dict[term, tf-idf]]` -- **更新:** DebounceBatch(500ms)在文件更改时,完全重建则在重新索引时 -- **性能:** O(log N) 每查询 - -```mermaid -flowchart LR - subgraph BM25[BM25 Index Building] - A[LanceDB chunks] --> B[Tokenize + TF] - B --> C[Compute IDF] - C --> D[Score matrix\nterm → doc → tf-idf] - end - subgraph BM25Q[BM25 Query] - Q[Query] --> QT[Tokenize] - QT --> SC[Sum tf-idf per doc] - SC --> TOP[Top-k results] - end -``` +## 3. Candidate 来源 -### 3. 稠密搜索(向量,LanceDB) +### 3.1 SQLite FTS5/BM25 -- **目的:** 语义相似度 — 查找概念相关的代码 -- **模型:** `multilingual-e5-small-int8`(384 维) -- **提供者(provider):** ONNX INT8 进程内(主要)/ LM Studio(回退 fallback) -- **索引:** 带 IVF-PQ 量化的 LanceDB v2 +`SQLiteChunkStore.search` 从查询词构造 FTS5 `MATCH`(OR、quoted),用 SQLite +`bm25()` 排序,返回: -```python -async def dense_search(query_vector: list, limit: int) -> list: - table = await ensure_async_table() - builder = await table.search(query_vector, vector_column_name="vector") - df = await builder.limit(limit).to_pandas() - return [{"text": row["text"], "metadata": {...}} for _, row in df.iterrows()] -``` +- chunk text 和 full text; +- file path、chunk index、symbol name/kind、docstring、layer; +- start/end line; +- 由 BM25 rank 得到的 `final_score`。 -### 4. RRF 融合(倒数排序融合 Reciprocal Rank Fusion) - -> ⚠️ **重要:** 排名**分别**为每个通道计算,从 1 开始。 -> 使用合并的 `enumerate(bm25 + dense)` 会得出不正确的分数。 - -```python -def rrf_fusion(bm25: list, dense: list, k: int = 60) -> list: - """Merge BM25 + dense results using RRF formula. - - Ранги считаются отдельно для каждого канала (начиная с 1), - как того требует математически корректная RRF. - """ - scores = {} - results_map = {} - - # BM25 ранги (отдельный enumerate с start=1) - for rank, doc in enumerate(bm25, 1): - key = f"{doc['metadata']['file']}:{doc['metadata']['chunk_index']}" - scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank) - if key not in results_map: - results_map[key] = { - **doc, - "bm25_score": 1.0 / (k + rank), - "dense_score": 0.0, - } - else: - results_map[key]["bm25_score"] = 1.0 / (k + rank) - - # Dense ранги (отдельный enumerate с start=1) - for rank, doc in enumerate(dense, 1): - key = f"{doc['metadata']['file']}:{doc['metadata']['chunk_index']}" - scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank) - if key not in results_map: - results_map[key] = { - **doc, - "bm25_score": 0.0, - "dense_score": 1.0 / (k + rank), - } - else: - results_map[key]["dense_score"] = 1.0 / (k + rank) - - for key in results_map: - results_map[key]["final_score"] = ( - results_map[key]["bm25_score"] + results_map[key]["dense_score"] - ) - - return sorted(results_map.values(), key=lambda x: x["final_score"], reverse=True) -``` +表是 content-linked(`content='chunks'`),带 insert/update/delete triggers; +删除文件会自动删除对应 FTS rows。 -### 5. 多桶 RAG(Multi-Bucket RAG) - -```mermaid -flowchart LR - subgraph Buckets - CODE[Code Bucket\nweight=1.0] - DOCS[Docs Bucket\nweight=1.0] - end - subgraph Intent - CODE_HINT[intent_hint=code\ncode*1.2, docs*0.8] - DOCS_HINT[intent_hint=docs\ncode*0.8, docs*1.2] - AUTO[intent_hint=auto\nneutral] - end - subgraph Extension Detection - EXT[os.path.splitext\n→ .py, .rs = code\n→ .md, .json = docs] - end - RESULTS[Scored results] --> EXT --> |code ext| CODE - EXT --> |docs ext| DOCS - CODE --> |multiply by| CODE_HINT - CODE_HINT --> FINAL[final_score] - DOCS --> |multiply by| DOCS_HINT - DOCS_HINT --> FINAL -``` +### 3.2 符号与路径信号 -### 6. 协同变更提升(Co-change Boost) - -使用 git 历史提升历史上一起变更的文件: - -```python -def apply_co_change_boost(chunks: list) -> list: - """Boost files coupled with top-3 results via git history.""" - top_files = {c["metadata"]["file"] for c in chunks[:3]} - matrix = commit_memory.compute_co_change_matrix() - - for chunk in chunks: - file = chunk["metadata"]["file"] - partners = matrix.get(file, {}) - if partners and any(tf in partners for tf in top_files): - best = max(partners.get(tf, 0) for tf in top_files) - chunk["final_score"] *= (1.0 + best * 0.3) - return chunks -``` +Identifier-shaped 查询(`snake_case`、`CamelCase`、paths、`::`)会走确定性的 +graph stage。图返回 exact definition/reference rows,避免 FTS 把它们排在通用 +文本匹配之后。按 `file + chunk_index` 去重。 -### 7. 交叉编码器重排序(Cross-encoder Reranker) +### 3.3 文档通道 -**模型:** `bge-reranker-v2-m3`(通过 LM Studio / Ollama) +`Searcher.search_docs()` 使用同一 FTS5 store,只保留 `docs/**`。它把语言变体 +(`docs/en/X`、`docs/ru/X`、`docs/zh/X`)折叠为一个 key,并优先查询语言。 +这是独立通道,不改变代码 ranking。 -> **注意:** 重排序器(reranker)的可视化假设使用 LM Studio(GPU)。使用 ONNX Runtime 回退(fallback)时,重排序器通过 ONNX Runtime(CPU)使用相同的 `bge-reranker-v2-m3` 模型,吞吐量降低。 +### 3.4 可选 dense/vector path -- 独立评估每个(查询,块(chunk))对 — **比向量余弦更准确** -- 仅对前 30 个候选进行重排序(由 `MAX_RERANKER_INPUT` 控制) -- 如果 LM Studio 不可用,优雅降级 +当 `MSCODEBASE_DENSE_OFF=false` 且 provider 可用时,vector search 走 LanceDB +或配置的 backend。该路径可增加 candidates,但不是默认契约的必需项。 -```python -async def rerank(query: str, candidates: list, top_n: int = 5) -> list: - """Single-stage reranker: query + chunk → relevance score.""" - if not candidates: - return candidates - scores = await multi_reranker.rerank(query, candidates) - for i, chunk in enumerate(candidates): - chunk["final_score"] = scores[i] if i < len(scores) else 0 - return sorted(candidates, key=lambda x: x["final_score"], reverse=True)[:top_n] -``` +## 4. Ranking 与 fusion + +默认 Phase 3: + +1. SQLite 的 BM25 顺序; +2. identifier 查询追加 graph candidates; +3. 可选 on-demand rerank top pool; +4. identifier 查询的 exact-name boost; +5. 按 symbol 去重。 -## 完整序列图 - -```mermaid -sequenceDiagram - participant Agent as AI Agent - participant MCP as MCP Server - participant EB as @error_boundary - participant ST as SearchTool - participant S as Searcher - participant E as Embedder - participant I as Indexer - participant R as Reranker - - Agent->>MCP: search_code(query, mode="quality", intent_hint="code") - MCP->>EB: wrap(timeout=10000, retries=2) - EB->>ST: execute() - - ST->>S: hybrid_search_async(query, limit=5) - - par Sparse search - S->>I: _bm25_search(query) - I-->>S: BM25 candidates - and Dense search - S->>E: embed_batch_async([query]) - E-->>S: query vector (768-dim) - S->>I: search_async(vector, limit=30) - I-->>S: dense candidates - end - - S->>S: RRF fusion (k=60) - S->>S: Bucket weighting (code intent) - S->>S: Co-change boost - - S->>R: rerank(query, top-15, top_n=5) - R-->>S: reranked scores - - S-->>ST: 5 final results - ST-->>EB: formatted response - EB-->>MCP: success + metadata - MCP-->>Agent: results with file paths +可选/历史路径: + +- BM25、dense、FTS5 的 reciprocal rank fusion; +- 按 intent 和文件类型做 bucket weighting; +- co-change 和 exact-symbol boosts; +- non-code demotion(`tests/`、`docs/`、`experiments/`、`fixtures/`); +- MMR diversification; +- identifier anchors 和 P3 module-head anchors; +- multi-provider reranking。 + +这些阶段可配置,不会应用到每个查询。不要用旧 pipeline 描述预测默认结果顺序。 + +## 5. 文档路由 + +路由是一次廉价比较: + +```text +docs_max = docs/** 中最佳 BM25 score +code_max = src/**、tools/**、scripts/**、tests/** 中最佳 BM25 score +if docs_max - code_max > MSCODEBASE_DOCS_MARGIN: + use search_docs() +else: + use code search ``` -## 性能基准测试 - -| 阶段 | 时间 | 累积 | -|-------|:----:|:----------:| -| 查询扩展 | <1ms | <1ms | -| BM25 搜索 | ~150ms | ~150ms | -| 嵌入查询 | ~800ms | ~950ms | -| LanceDB ANN | ~400ms | ~1350ms | -| RRF 融合 | <1ms | ~1350ms | -| 桶加权 | <1ms | ~1350ms | -| 协同变更提升 | ~50ms | ~1400ms | -| 重排序器(reranker)(5 个候选) | ~1200ms | ~2600ms | -| **总计(质量模式)** | **~5600ms** | | -| **总计(快速模式,仅 BM25)** | **~2300ms** | | -| **总计(深度模式,递归)** | **2-5s** | | - -> *时间基于 ONNX Runtime(CPU)。LM Studio(GPU)可快 3-5 倍。* - -## 配置 - -```ini -# .env 搜索设置 -DEFAULT_SEARCH_LIMIT=6 -MAX_SEARCH_RESULTS=20 -QUERY_SYNONYMS_ENABLED=true -MAX_QUERY_EXPANSIONS=3 -OVERFETCH_FACTOR=3 -RERANKER_PROVIDERS=ollama,lm_studio - -# 桶权重(1.0 = 中性) -CODE_BUCKET_WEIGHT=1.0 -DOCS_BUCKET_WEIGHT=1.0 +特性: + +- 一次额外 FTS 查询,无模型调用; +- 显式 `intent` 覆盖启发式; +- threshold 可配置,并在 48 查询面板上调过; +- 带强文档词汇的代码查询仍可能误判;`intent="code"` 是退路。 + +面板上 margin `4.0` 得到 0 个 code false positive、8 个 docs 问题中 5 个 +top-1,以及总体 0.792 same-basename hit@1。这是面板结果,不是通用常数。 + +## 6. 模式 + +| Mode | 路径 | 用途 | +|---|---|---| +| `fast` | FTS5 + 可选 dense;无 reranker | 精确名称、已知路径、低延迟 | +| `quality` | 默认 hybrid + 可选 rerank | 常规代码/文档问题 | +| `deep` | Quality + graph context expansion | 跨模块调查 | +| `context` | 按片段做相似代码搜索 | 查找相似实现 | +| `ask` | Retrieval + 可选本地 LLM 生成 | 仅在配置 LLM profile 时 | +| `auto` | 按查询形态选择模式 | 默认 tool 入口 | + +在内部面板上,`fast` 在混合自然语言问题上明显不如 `quality`(0.542 vs +0.688 hit@1)。请有意识地使用。 + +## 7. 可选层 + +| 层 | 开关 / 条件 | 效果 | +|---|---|---| +| Dense retrieval | `MSCODEBASE_DENSE_OFF=false` + provider | 增加 vector candidates | +| On-demand rerank | `MSCODEBASE_ONDEMAND_RERANK=true` + provider | 重排 top candidates | +| P3 module-head anchors | `MSCODEBASE_P3_ANCHOR=true` | 增加 module-head chunks | +| RU synonyms | `MSCODEBASE_RU_SYNONYMS=true` | 扩展俄语查询变体 | +| Non-code demotion | `MSCODEBASE_DEMOTE_NON_CODE=true` | 降低 tests/docs/experiments | +| Intent-aware demotion | `MSCODEBASE_DEMOTE_INTENT_AWARE=true` | docs intent 时保留 docs | +| Docs auto-route | `MSCODEBASE_DOCS_MARGIN` | 选择 docs/code 通道 | + +## 8. 输出契约 + +每个结果携带足够 metadata: + +- `file`、`chunk_index`、`start_line`、`end_line`; +- `symbol_name`、`symbol_kind`、`layer`; +- `final_score` 和 `source`(`sqlite_fts5`、`graph`、`docs_fts` 等); +- 可选 `docstring`、callers/callees、security flags。 + +Tool 响应可能包含 stale-index warning。Warning 不是搜索失败,而是提醒 agent +在信任 freshness 前刷新索引。 + +## 9. 测量行为 + +内部面板:48 个真实查询,clean index,排除 `experiments/**`,图存在。 + +| 路径 | hit@1 | hit@5 | +|---|---:|---:| +| Code, quality | 0.825 | 0.900 | +| Docs, exact path | 0.000 | 0.125 | +| Docs, same basename | 0.750 | 0.750 | +| Overall quality | 0.688 | 0.750 | +| Overall auto docs route | 0.792 | 0.875 | +| Fast | 0.542 | 0.729 | + +边界: + +- 这是内部面板,不是通用 benchmark; +- docs exact-path 会低估正确翻译; +- 没有静态配置或已测试的本地 reranker 达到 95% hit@1; +- two-channel repo map 在 1k/2k/4k token、理想 routing 下约 40/62/73% + gold-file coverage;单独 PageRank 更低。 + +## 10. 调试搜索 + +```text +debug_runtime_passport() +intel_get_runtime_status() +codebase(action="index", path="status") +search_code(query="...", explain=true) ``` + +有用的检查: + +1. `chunks.db` 是否非空?否则运行 `scripts/reindex_text_only.py`。 +2. `graph.db` 是否为空?运行 `scripts/rebuild_graph.py`。 +3. 查询像 docs?尝试 `intent="docs"` / `intent="code"`。 +4. 在判定 docs miss 前,比较 exact-path 与 same-basename。 +5. 对 `fast` vs `quality`,同一查询分别运行并比较。 + +## 11. 非目标 + +- 为每个查询使用 LLM router。路由是词法、廉价的。 +- 用静态 repo map 替代迭代搜索。它是上下文选择,不是 oracle。 +- 根据小型内部面板宣称 95% retrieval accuracy。 +- 用自信回答掩盖空图或 stale index。 From 13e235f681b0d47210e994ceb67ae47c337702b5 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 21:21:59 +0300 Subject: [PATCH 03/12] fix(search): tolerate fake searchers without a chunk store P3 anchor degradation must not assume every Searcher instance has _chunk_store. The unit-test doubles exercise the degraded path directly; use getattr and keep the deterministic fallback. --- src/core/search/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/search/engine.py b/src/core/search/engine.py index 023e64a3..cc6ab718 100644 --- a/src/core/search/engine.py +++ b/src/core/search/engine.py @@ -915,7 +915,7 @@ async def _anchor_module_head_chunks_async( df = self._bm25_df except Exception: # noqa: BLE001 — degraded BM25 never breaks search df = None - if df is None and self._chunk_store is None: + if df is None and getattr(self, "_chunk_store", None) is None: return pool have = set() for r in pool: From a01c738b5a2772589b36f719a3fc1c3228b8b4e3 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 21:46:10 +0300 Subject: [PATCH 04/12] fix(deps): bump certifi, filelock and mcp for CVE fixes Address pip-audit findings: certifi 2026.10.19, filelock 4.2.1, mcp 1.30.0. MCP stays on the 1.x transport; 2.x changes the protocol and is intentionally not adopted here. pip-audit on the lockfile now reports no known vulnerabilities. --- pyproject.toml | 2 +- requirements-lock.txt | 6 +++--- requirements.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2fae2a2..68f5336e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ # Исключение: numpy/pandas/onnxruntime на Python<3.11 (CI matrix включает 3.10, # EOL 2026-10; lock-версия требует >=3.11) — там per-Python диапазон; после дропа 3.10 запинить. # MCP Protocol - "mcp==1.28.1", # 1.x: транспортный протокол; 2.x меняет его — не бампить без проверки + "mcp==1.30.0", # 1.30.0: security fix (CVE-2026-59951); 2.x меняет транспорт — не бампить без проверки # PyYAML: pnpm-lock.yaml в src/sources/manifest (B-1). Единственная НЕ-stdlib # зависимость экстракторов; версия из requirements-lock.txt (венв, live 6.0.3, # API yaml.safe_load проверен). Бамп — по §5.19. diff --git a/requirements-lock.txt b/requirements-lock.txt index a6844648..08637cb2 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -3,7 +3,7 @@ annotated-types==0.7.0 anyio==4.14.2 attrs==26.1.0 cattrs==26.1.0 -certifi==2026.6.17 +certifi==2026.10.19 cffi==2.1.0 click==8.4.2 colorama==0.4.6 @@ -11,7 +11,7 @@ colorama==0.4.6 # Verified: pyjwt 2.15.0 + cryptography 50.0.0 RS256 roundtrip + import mcp ok. cryptography==50.0.0 deprecation==2.1.0 -filelock==3.31.0 +filelock==4.2.1 flatbuffers==25.12.19 fsspec==2026.6.0 h11==0.16.0 @@ -28,7 +28,7 @@ lance-namespace-urllib3-client==0.8.6 lancedb==0.34.0 lsprotocol==2025.0.0 markdown-it-py==4.2.0 -mcp==1.28.1 +mcp==1.30.0 mdurl==0.1.2 numpy==2.4.6 onnxruntime==1.27.0 diff --git a/requirements.txt b/requirements.txt index 2aa2ddd0..360f72ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ # Поддержка ТОЛЬКО Python 3.14+ (2026-08-21) — маркеров python_version нет. # MCP Protocol -mcp==1.28.1 # 1.x: транспортный протокол; 2.x меняет его — не бампить без проверки +mcp==1.30.0 # 1.30.0: security fix (CVE-2026-59951); 2.x меняет транспорт — не бампить без проверки # PyYAML: pnpm-lock.yaml в src/sources/manifest (B-1); единственная НЕ-stdlib # зависимость экстракторов; версия из requirements-lock.txt (венв, live 6.0.3). PyYAML==6.0.3 From c1fb3a9736dd14fb5f772a38225eba32a760b046 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 21:46:34 +0300 Subject: [PATCH 05/12] test(search): align dense and cache tests with Phase 3 defaults The default runtime is text-first: dense_off and chunk augmentation are on, and on-demand rerank defaults on. Tests that exercise dense/cache paths now opt back into them explicitly instead of asserting the old defaults. --- tests/test_chunk_augment.py | 12 ++++++------ tests/test_chunk_cache.py | 13 +++++++++++++ tests/test_dense_off_flag.py | 8 ++++---- tests/test_hybrid_cache.py | 2 ++ tests/test_ondemand_rerank.py | 4 ++-- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/tests/test_chunk_augment.py b/tests/test_chunk_augment.py index 3293569b..57db50f3 100644 --- a/tests/test_chunk_augment.py +++ b/tests/test_chunk_augment.py @@ -2,8 +2,8 @@ - Template exactness is pinned (frozen AUG_TEMPLATE): FILE/SYMBOLS/DOC order, dedup, 500-char doc cap, backslash normalization, empty cases. -- Wiring: flag OFF (default) leaves parse output byte-identical; - flag ON prefixes every chunk (AST and fallback share the choke point). +- Wiring: flag ON (Phase 3 default) prefixes every chunk; flag OFF leaves + parse output byte-identical (AST and fallback share the choke point). - Formula guard lives in experiments (E26 exact TF*IDF); here we pin the INPUT. """ from __future__ import annotations @@ -65,9 +65,9 @@ def test_build_file_prefix_empty_case(): assert build_file_prefix("x.py", "x = 1\n") == "FILE: x.py\nSYMBOLS: \nDOC: \n" -def test_flag_default_off_and_parsing(monkeypatch): +def test_flag_default_on_and_parsing(monkeypatch): monkeypatch.delenv("MSCODEBASE_AUGMENT_CHUNKS", raising=False) - assert augment_enabled() is False + assert augment_enabled() is True def test_flag_on_values(monkeypatch): @@ -87,7 +87,7 @@ def _make_parser(tmp_path): def test_wiring_off_leaves_texts_unchanged(tmp_path, monkeypatch): - monkeypatch.delenv("MSCODEBASE_AUGMENT_CHUNKS", raising=False) + monkeypatch.setenv("MSCODEBASE_AUGMENT_CHUNKS", "false") f = tmp_path / "m.py" f.write_text(BODY, encoding="utf-8") parsed = _make_parser(tmp_path).parse_file(f, "m.py") @@ -111,7 +111,7 @@ def test_wiring_on_prefixes_every_chunk(tmp_path, monkeypatch): def test_wiring_on_off_differ_only_by_prefix(tmp_path, monkeypatch): f = tmp_path / "m.py" f.write_text(BODY, encoding="utf-8") - monkeypatch.delenv("MSCODEBASE_AUGMENT_CHUNKS", raising=False) + monkeypatch.setenv("MSCODEBASE_AUGMENT_CHUNKS", "false") off = _make_parser(tmp_path).parse_file(f, "m.py") monkeypatch.setenv("MSCODEBASE_AUGMENT_CHUNKS", "true") on = _make_parser(tmp_path).parse_file(f, "m.py") diff --git a/tests/test_chunk_cache.py b/tests/test_chunk_cache.py index 6268dd15..92a5f58d 100644 --- a/tests/test_chunk_cache.py +++ b/tests/test_chunk_cache.py @@ -12,10 +12,23 @@ from unittest.mock import MagicMock import pandas as pd +import pytest from src.core.indexing.index_pipeline import IndexPipeline +@pytest.fixture(autouse=True) +def _text_cache_env(monkeypatch): + """These tests exercise embedding cache, not the text-only default.""" + monkeypatch.setenv("MSCODEBASE_AUGMENT_CHUNKS", "false") + monkeypatch.setenv("MSCODEBASE_DENSE_OFF", "false") + from src.config.settings import reload_config + + reload_config() + yield + reload_config() + + def _chunk_hash(text: str) -> str: return "ch:" + hashlib.sha256(text.encode("utf-8")).hexdigest()[:32] diff --git a/tests/test_dense_off_flag.py b/tests/test_dense_off_flag.py index f49662d9..bf215d9b 100644 --- a/tests/test_dense_off_flag.py +++ b/tests/test_dense_off_flag.py @@ -1,6 +1,6 @@ """E28 (Фаза 3): dense-off флаг — гасит dense-тир, ничего не ломая. -Контракт: default OFF (плот-ап путь не тронут), флаг гасит и эмбеддинг запроса, +Контракт Phase 3: default ON (text-first), флаг гасит и эмбеддинг запроса, и vector search; BM25/FTS5/graph продолжают работать. """ from __future__ import annotations @@ -13,11 +13,11 @@ sys.path.insert(0, str(ROOT)) -def test_flag_default_off(): +def test_flag_default_on(): from src.config.settings import SearchConfig - assert os.environ.get("MSCODEBASE_DENSE_OFF", "false") == "false" - assert SearchConfig().dense_off is False + assert os.environ.get("MSCODEBASE_DENSE_OFF", "true") == "true" + assert SearchConfig().dense_off is True def test_env_enables_flag(monkeypatch): diff --git a/tests/test_hybrid_cache.py b/tests/test_hybrid_cache.py index e41d0378..0dbb165f 100644 --- a/tests/test_hybrid_cache.py +++ b/tests/test_hybrid_cache.py @@ -15,6 +15,8 @@ def _make_searcher(): searcher = Searcher(MagicMock(), MagicMock()) + searcher._dense_off = False + searcher._chunk_store = None searcher._bm25_search_async = AsyncMock(return_value=[]) searcher._fts5_search_async = AsyncMock(return_value=[]) searcher._ensure_multi_reranker_async = AsyncMock(return_value=None) diff --git a/tests/test_ondemand_rerank.py b/tests/test_ondemand_rerank.py index 9b98d2c1..440bf88e 100644 --- a/tests/test_ondemand_rerank.py +++ b/tests/test_ondemand_rerank.py @@ -65,10 +65,10 @@ def test_split_prefers_text_full_and_counts_skipped(): assert len(usable) == 2 -def test_engine_wiring_present_and_flag_defaults_off(): +def test_engine_wiring_present_and_flag_defaults_on(): import os - assert os.getenv("MSCODEBASE_ONDEMAND_RERANK", "false") == "false" + assert os.getenv("MSCODEBASE_ONDEMAND_RERANK", "true") == "true" from src.core.search import engine as E assert hasattr(E.Searcher, "_apply_ondemand_reranker_async") From 7178e3425aaa2b53fee3fa8e8510ff1a060d2111 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 21:57:53 +0300 Subject: [PATCH 06/12] fix(search): require a real boolean from the docs auto-router Mocked searchers return a truthy MagicMock from prefers_docs, which routed every test and every non-Searcher double into the docs channel. Require is True so only the real BM25-margin decision can switch channels. --- src/mcp/tools/search_tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp/tools/search_tools.py b/src/mcp/tools/search_tools.py index 0916f877..6203961b 100644 --- a/src/mcp/tools/search_tools.py +++ b/src/mcp/tools/search_tools.py @@ -253,7 +253,8 @@ async def execute( # Explicit intent="code" still wins; empty intent is auto. if not intent: _auto_searcher = self.resolve_searcher(explicit_project_root=_pr or None) - if _auto_searcher.prefers_docs(query): + _prefers = getattr(_auto_searcher, "prefers_docs", None) + if callable(_prefers) and _prefers(query) is True: intent = "docs" if intent == "docs": From 189fac8fb277f628d6afadd8dd4b2a50f54b5be6 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 21:58:19 +0300 Subject: [PATCH 07/12] docs(tools): report 66 registered and 67 optional tools The registered set now has 33 core classes + 16 intel + 13 inline + 4 dev = 66; execute_script is the 67th optional tool. Update the EN/RU/ZH contract and the real-project guard instead of publishing 64/65. The exact default-visible set is described as a curated allowlist rather than a stale hardcoded count. --- README.md | 6 +- docs/en/ARCHITECTURE.md | 14 +-- docs/ru/ARCHITECTURE.md | 10 +- docs/ru/README.md | 6 +- docs/zh/ARCHITECTURE.md | 10 +- docs/zh/README.md | 6 +- tests/test_auto_doc_updater.py | 2 +- tests/test_factory.py | 224 +++++++++++++++++++++++++++++++++ 8 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 tests/test_factory.py diff --git a/README.md b/README.md index ea11d810..12e4bbe2 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,9 @@ Then restart the MCP server/Zed so the new process opens the rebuilt stores. ## Tools -The server registers **64 tools** in four groups; `execute_script` adds one +The server registers **66 tools** in four groups; `execute_script` adds one optional tool when `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. The default -allowlist exposes **49** of them; hidden tools can be enabled with +allowlist exposes a curated subset of them; hidden tools can be enabled with `MSCODEBASE_MCP_TOOLS` (empty string shows all). The exact visible set is reported in the MCP startup log and by `intel_get_runtime_status`. @@ -250,7 +250,7 @@ These are deliberate, documented boundaries rather than hidden behavior: experiment and is not the default path. - **Fast mode is not quality mode:** it is faster but materially less accurate on mixed natural-language questions. Use it for exact names and known paths. -- **Visible tools are allowlisted:** seeing "64 tools" in source does not mean +- **Visible tools are allowlisted:** seeing "66 tools" in source does not mean all 64 are exposed to a given client; check `MSCODEBASE_MCP_TOOLS`. ## Reporting a bug diff --git a/docs/en/ARCHITECTURE.md b/docs/en/ARCHITECTURE.md index 2b1fb2b3..4f1443be 100644 --- a/docs/en/ARCHITECTURE.md +++ b/docs/en/ARCHITECTURE.md @@ -39,7 +39,7 @@ Zed ▼ server_factory.create_mcp_server() ├── create_service_collection() DI container - ├── register_all_tools() 64 registered tools + ├── register_all_tools() 66 registered tools └── notification / extension hooks │ ├── Search core/search @@ -108,17 +108,17 @@ resources and releases background tasks. ## 6. Tool layer -The server registers **64 tools**: +The server registers **66 tools**: -- 31 core tool classes (search, LSP, graph, analysis, write, lifecycle); +- 33 core tool classes (search, LSP, graph, analysis, write, lifecycle); - 16 `intel_*` tools; - 13 inline diagnostic tools; - 4 dev tools. -`execute_script` is a 65th, optional tool and is registered only when -`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. The default allowlist exposes **49** -tools; `MSCODEBASE_MCP_TOOLS` controls visibility (an empty value shows all -registered tools). +`execute_script` is a 67th, optional tool and is registered only when +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. The default allowlist exposes a +curated subset; `MSCODEBASE_MCP_TOOLS` controls visibility (an empty value shows +all registered tools). `codebase(action=...)` is a hub over index, git and write operations. Search has a dedicated `search_code` tool and a `plan_search` helper that returns a diff --git a/docs/ru/ARCHITECTURE.md b/docs/ru/ARCHITECTURE.md index 2a4f90bf..44173c23 100644 --- a/docs/ru/ARCHITECTURE.md +++ b/docs/ru/ARCHITECTURE.md @@ -38,7 +38,7 @@ Zed ▼ server_factory.create_mcp_server() ├── create_service_collection() DI container - ├── register_all_tools() 64 registered tools + ├── register_all_tools() 66 registered tools └── notification / extension hooks │ ├── Search core/search @@ -104,15 +104,15 @@ indexer и не открывают БД напрямую. Shutdown закрыв ## 6. Tool layer -Сервер регистрирует **64 инструмента**: +Сервер регистрирует **66 инструментов**: -- 31 core tool class (search, LSP, graph, analysis, write, lifecycle); +- 33 core tool class (search, LSP, graph, analysis, write, lifecycle); - 16 `intel_*`; - 13 inline diagnostic; - 4 dev. -`execute_script` — 65-й опциональный инструмент, только при -`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. Default allowlist показывает **49**; +`execute_script` — 67-й опциональный инструмент, только при +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. Default allowlist показывает выбранное подмножество; `MSCODEBASE_MCP_TOOLS` управляет видимостью (пусто — все зарегистрированные). `codebase(action=...)` — hub над index, git и write. У поиска есть diff --git a/docs/ru/README.md b/docs/ru/README.md index 9848f03c..0e8f7881 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -150,9 +150,9 @@ python scripts/reindex_text_only.py --project . ## Инструменты -Сервер регистрирует **64 инструмента** в четырёх группах; `execute_script` +Сервер регистрирует **66 инструментов** в четырёх группах; `execute_script` добавляет один опциональный инструмент при -`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. Default allowlist показывает **49**; +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`. Default allowlist показывает выбранное подмножество; скрытые инструменты включаются через `MSCODEBASE_MCP_TOOLS` (пустая строка — показать все). Точный набор виден в startup-логе MCP и в `intel_get_runtime_status`. @@ -243,7 +243,7 @@ C++, Scala и Dart. SQL, YAML, TOML, HTML, CSS и HCL/Terraform индексир эксперимент и не дефолтный путь. - **Fast mode — не quality mode:** быстрее, но заметно менее точен на смешанных natural-language вопросах. Для exact names и известных путей. -- **Видимые tools — allowlist:** «64 tools» в исходниках не значит, что все 64 +- **Видимые tools — allowlist:** «66 tools» в исходниках не значит, что все 64 видны конкретному клиенту; проверяйте `MSCODEBASE_MCP_TOOLS`. ## Как сообщать о баге diff --git a/docs/zh/ARCHITECTURE.md b/docs/zh/ARCHITECTURE.md index c97f806b..a30e0076 100644 --- a/docs/zh/ARCHITECTURE.md +++ b/docs/zh/ARCHITECTURE.md @@ -35,7 +35,7 @@ Zed ▼ server_factory.create_mcp_server() ├── create_service_collection() DI container - ├── register_all_tools() 64 registered tools + ├── register_all_tools() 66 registered tools └── notification / extension hooks │ ├── Search core/search @@ -99,15 +99,15 @@ Tool class 通过 constructor injection 获取 collection,不直接创建 inde ## 6. Tool 层 -服务器注册 **64 个工具**: +服务器注册 **66 个工具**: -- 31 个 core tool class(search、LSP、graph、analysis、write、lifecycle); +- 33 个 core tool class(search、LSP、graph、analysis、write、lifecycle); - 16 个 `intel_*`; - 13 个 inline diagnostic; - 4 个 dev。 -`execute_script` 是第 65 个可选工具,仅在 -`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true` 时注册。默认 allowlist 暴露 **49** +`execute_script` 是第 67 个可选工具,仅在 +`MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true` 时注册。默认 allowlist 暴露精选子集 个;`MSCODEBASE_MCP_TOOLS` 控制可见性(空值显示全部已注册工具)。 `codebase(action=...)` 是 index、git、write 的 hub。搜索有 `search_code` 和 diff --git a/docs/zh/README.md b/docs/zh/README.md index 749dca15..918c9167 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -142,9 +142,9 @@ python scripts/reindex_text_only.py --project . ## 工具 -服务器注册 **64 个工具**,分为四组;启用 +服务器注册 **66 个工具**,分为四组;启用 `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true` 时增加一个可选的 -`execute_script`。默认 allowlist 暴露 **49 个**;隐藏工具可通过 +`execute_script`。默认 allowlist 暴露精选子集;隐藏工具可通过 `MSCODEBASE_MCP_TOOLS` 启用(空字符串显示全部)。准确列表见 MCP 启动日志和 `intel_get_runtime_status`。 @@ -229,7 +229,7 @@ TypeScript/TSX、Rust、Go、Java、C#、Ruby、PHP、Kotlin、Swift、C、C++ LLM planner/agent loop 是另一个实验,不是默认路径。 - **Fast mode 不是 quality mode:** 更快,但在混合自然语言问题上明显更不准确。 用于精确名称和已知路径。 -- **可见工具受 allowlist 控制:** 源码里的“64 tools”不代表某个客户端能看到 +- **可见工具受 allowlist 控制:** 源码里的“66 tools”不代表某个客户端能看到 全部 64 个;检查 `MSCODEBASE_MCP_TOOLS`。 ## 报告问题 diff --git a/tests/test_auto_doc_updater.py b/tests/test_auto_doc_updater.py index 1b006c60..84f36085 100644 --- a/tests/test_auto_doc_updater.py +++ b/tests/test_auto_doc_updater.py @@ -156,7 +156,7 @@ def test_count_tools_real_project_guard(): tools = AutoDocUpdater()._count_tools(root) assert tools >= 44, f"_count_tools вернул {tools} — снова баг подсчёта?" if os.environ.get("MSCODEBASE_EXECUTE_SCRIPT_ENABLED", "false").lower() != "true": - assert tools == 65, f"ожидалось 65 (README-контракт), получено {tools}" + assert tools == 66, f"ожидалось 66 (README-контракт), получено {tools}" def test_update_readme_tools_count_is_env_independent(tmp_path, monkeypatch): diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 00000000..682591ec --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,224 @@ +"""Tests for the factory: the gate, the rule brain, the journal, the replay check.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "factory")) + +from ops import BY_NAME, OpError, op_grep # noqa: E402 +from plan import flatten, from_dict, resolve, rule_plan, validate # noqa: E402 +from runtime import Budget, Journal, run_plan, verify # noqa: E402 + + +def test_rule_plan_find_then_test(): + root = rule_plan("найди ActionReceipt затем запусти тесты tests/test_write_tools.py") + prims = flatten(root) + assert [n.op for n in prims] == ["grep", "read", "run"] + assert prims[1].args["path"] == "$t1.hits.0.file" + assert validate(root) == [] + + +def test_gate_rejects_unknown_operator(): + node = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "rm_rf", "args": {}}]}) + assert any("unknown operator" in e for e in validate(node)) + + +def test_gate_rejects_forward_reference(): + node = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "read", + "args": {"path": "$t2.hits.0.file"}}, + {"id": "t2", "kind": "primitive", "op": "grep", "args": {"pattern": "x"}}]}) + assert any("not an earlier node" in e for e in validate(node)) + + +def test_gate_rejects_unknown_arg(): + node = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "grep", "args": {"pattern": "x", "nope": 1}}]}) + assert any("unknown args" in e for e in validate(node)) + + +def test_gate_rejects_empty_compound(): + node = from_dict({"id": "root", "kind": "compound", "children": []}) + assert any("without children" in e for e in validate(node)) + + +def test_shell_allowlist_blocks_arbitrary_commands(tmp_path): + with pytest.raises(OpError): + BY_NAME["run"].fn(tmp_path, {"cmd": "del /q *"}) + + +def test_write_cannot_escape_repo(tmp_path): + with pytest.raises(OpError): + BY_NAME["write"].fn(tmp_path, {"path": "../escaped.txt", "content": "x"}) + + +def test_grep_excludes_build_and_factory(tmp_path): + (tmp_path / "build").mkdir() + (tmp_path / "build" / "copy.py").write_text("TARGET = 1\n", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "real.py").write_text("TARGET = 1\n", encoding="utf-8") + out = op_grep(tmp_path, {"pattern": "TARGET"}) + assert [h["file"] for h in out["hits"]] == ["src/real.py"] + + +def test_resolve_dotted_reference(): + results = {"t1": {"hits": [{"file": "a.py", "line": 7}]}} + assert resolve({"path": "$t1.hits.0.file", "start": "$t1.hits.0.line"}, results) == { + "path": "a.py", "start": 7} + + +def test_run_and_verify_pass(tmp_path): + (tmp_path / "a.py").write_text("VALUE = 42\n", encoding="utf-8") + root = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "grep", "args": {"pattern": "VALUE"}}, + {"id": "t2", "kind": "primitive", "op": "read", + "args": {"path": "$t1.hits.0.file", "start": "$t1.hits.0.line"}}, + ]}) + journal = tmp_path / "j.jsonl" + out = run_plan(root, tmp_path, journal, Budget()) + assert out["status"] == "ok" and out["steps"] == 2 + report = verify(journal, tmp_path) + assert report["verdict"] == "PASS" and report["chain_ok"] is True + assert all(n["verdict"] == "replayed-match" for n in report["nodes"]) + + +def test_verify_detects_tampering(tmp_path): + (tmp_path / "a.py").write_text("VALUE = 42\n", encoding="utf-8") + root = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "read", "args": {"path": "a.py"}}, + ]}) + journal = tmp_path / "j.jsonl" + run_plan(root, tmp_path, journal, Budget()) + lines = journal.read_text(encoding="utf-8").splitlines() + rec = json.loads(lines[0]) + rec["args"]["path"] = "b.py" # tamper after the fact + journal.write_text(json.dumps(rec) + "\n", encoding="utf-8") + report = verify(journal, tmp_path) + assert report["verdict"] == "FAIL" and report["chain_ok"] is False + + +def test_verify_detects_artifact_change(tmp_path): + target = tmp_path / "out.txt" + root = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "write", "args": {"path": "out.txt", "content": "hello"}}, + ]}) + journal = tmp_path / "j.jsonl" + run_plan(root, tmp_path, journal, Budget()) + assert verify(journal, tmp_path)["verdict"] == "PASS" + target.write_text("changed\n", encoding="utf-8") + report = verify(journal, tmp_path) + assert report["verdict"] == "FAIL" + assert report["nodes"][0]["verdict"] == "artifact-MISMATCH" + + +def test_budget_stops_the_run(tmp_path): + (tmp_path / "a.py").write_text("X = 1\n", encoding="utf-8") + root = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "read", "args": {"path": "a.py"}}, + {"id": "t2", "kind": "primitive", "op": "read", "args": {"path": "a.py"}}, + ]}) + out = run_plan(root, tmp_path, tmp_path / "j.jsonl", Budget(max_steps=1)) + assert out["status"] == "budget" and out["steps"] == 1 + + +def test_journal_chain_links(tmp_path): + journal = Journal(tmp_path / "j.jsonl") + a = journal.append(node="t1", op="grep", args={"pattern": "x"}, status="ok", + result={"hits": []}, error=None, duration_ms=1) + b = journal.append(node="t2", op="grep", args={"pattern": "y"}, status="ok", + result={"hits": []}, error=None, duration_ms=1) + assert b["prev"] == a["hash"] and b["seq"] == 2 + +# ---------------------------------------------------------------- ephemeral brain + +import json as _json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import brain as brain_mod + + +class _Stub(BaseHTTPRequestHandler): + reply = {"args": {"pattern": "ActionReceipt"}} + last_chars = 0 + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length", 0)) + body = _json.loads(self.rfile.read(length)) + chars = sum(len(m.get("content") or "") for m in body.get("messages", [])) + type(self).last_chars = chars + payload = _json.dumps({ + "choices": [{"message": {"content": _json.dumps(type(self).reply)}}], + "usage": {"prompt_tokens": chars // 4, "completion_tokens": 12}, + }).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): # silence + pass + + +@pytest.fixture() +def stub(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _Stub) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_port}/v1", _Stub + server.shutdown() + + +def test_single_action_prompt_is_small(): + probe = brain_mod.probe_offline(op="grep", goal="найди ActionReceipt") + assert probe["system_tokens_est"] < 80 + assert probe["total_tokens_est"] < 200 + + +def test_one_call_returns_args(stub): + endpoint, handler = stub + args, rec = brain_mod.decide(endpoint=endpoint, model="stub", node_id="t1", op="grep", + goal="найди ActionReceipt", last_digest="") + assert args == {"pattern": "ActionReceipt"} + assert rec.error is None + assert rec.prompt_tokens_est < 200 + assert rec.completion_tokens == 12 + + +def test_one_call_rejects_unknown_args(stub): + endpoint, handler = stub + handler.reply = {"args": {"zzz": 1}} + args, rec = brain_mod.decide(endpoint=endpoint, model="stub", node_id="t1", op="grep", + goal="x", last_digest="") + assert args == {} + assert "unknown arguments" in (rec.error or "") + handler.reply = {"args": {"pattern": "ActionReceipt"}} + + +def test_run_with_decider_makes_one_call_per_empty_node(tmp_path, stub): + endpoint, _ = stub + (tmp_path / "a.py").write_text("ActionReceipt = 1\n", encoding="utf-8") + root = from_dict({"id": "root", "kind": "compound", "children": [ + {"id": "t1", "kind": "primitive", "op": "grep", "args": {}}, + {"id": "t2", "kind": "primitive", "op": "read", + "args": {"path": "$t1.hits.0.file", "start": "$t1.hits.0.line"}}, + ]}) + calls = [] + + def decider(node, last_digest): + calls.append(node.id) + return brain_mod.decide(endpoint=endpoint, model="stub", node_id=node.id, op=node.op, + goal="найди ActionReceipt", last_digest=last_digest) + + out = run_plan(root, tmp_path, tmp_path / "j.jsonl", Budget(), decider=decider) + assert out["status"] == "ok" and out["steps"] == 2 + assert calls == ["t1"] # exactly one model call, for the only undecided node + assert len(out["brain_calls"]) == 1 + assert verify(tmp_path / "j.jsonl", tmp_path)["verdict"] == "PASS" From 3824f031e5194c0d39911b8a8027523a6fc6f6a3 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 22:04:25 +0300 Subject: [PATCH 08/12] chore(test): drop test_factory.py from this PR The file imports tools/factory/ops.py, which is not tracked in this branch. Keep the PR focused on the Phase 3 search and docs changes; the factory work needs its own commit with its implementation. --- tests/test_factory.py | 224 ------------------------------------------ 1 file changed, 224 deletions(-) delete mode 100644 tests/test_factory.py diff --git a/tests/test_factory.py b/tests/test_factory.py deleted file mode 100644 index 682591ec..00000000 --- a/tests/test_factory.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Tests for the factory: the gate, the rule brain, the journal, the replay check.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "factory")) - -from ops import BY_NAME, OpError, op_grep # noqa: E402 -from plan import flatten, from_dict, resolve, rule_plan, validate # noqa: E402 -from runtime import Budget, Journal, run_plan, verify # noqa: E402 - - -def test_rule_plan_find_then_test(): - root = rule_plan("найди ActionReceipt затем запусти тесты tests/test_write_tools.py") - prims = flatten(root) - assert [n.op for n in prims] == ["grep", "read", "run"] - assert prims[1].args["path"] == "$t1.hits.0.file" - assert validate(root) == [] - - -def test_gate_rejects_unknown_operator(): - node = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "rm_rf", "args": {}}]}) - assert any("unknown operator" in e for e in validate(node)) - - -def test_gate_rejects_forward_reference(): - node = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "read", - "args": {"path": "$t2.hits.0.file"}}, - {"id": "t2", "kind": "primitive", "op": "grep", "args": {"pattern": "x"}}]}) - assert any("not an earlier node" in e for e in validate(node)) - - -def test_gate_rejects_unknown_arg(): - node = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "grep", "args": {"pattern": "x", "nope": 1}}]}) - assert any("unknown args" in e for e in validate(node)) - - -def test_gate_rejects_empty_compound(): - node = from_dict({"id": "root", "kind": "compound", "children": []}) - assert any("without children" in e for e in validate(node)) - - -def test_shell_allowlist_blocks_arbitrary_commands(tmp_path): - with pytest.raises(OpError): - BY_NAME["run"].fn(tmp_path, {"cmd": "del /q *"}) - - -def test_write_cannot_escape_repo(tmp_path): - with pytest.raises(OpError): - BY_NAME["write"].fn(tmp_path, {"path": "../escaped.txt", "content": "x"}) - - -def test_grep_excludes_build_and_factory(tmp_path): - (tmp_path / "build").mkdir() - (tmp_path / "build" / "copy.py").write_text("TARGET = 1\n", encoding="utf-8") - (tmp_path / "src").mkdir() - (tmp_path / "src" / "real.py").write_text("TARGET = 1\n", encoding="utf-8") - out = op_grep(tmp_path, {"pattern": "TARGET"}) - assert [h["file"] for h in out["hits"]] == ["src/real.py"] - - -def test_resolve_dotted_reference(): - results = {"t1": {"hits": [{"file": "a.py", "line": 7}]}} - assert resolve({"path": "$t1.hits.0.file", "start": "$t1.hits.0.line"}, results) == { - "path": "a.py", "start": 7} - - -def test_run_and_verify_pass(tmp_path): - (tmp_path / "a.py").write_text("VALUE = 42\n", encoding="utf-8") - root = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "grep", "args": {"pattern": "VALUE"}}, - {"id": "t2", "kind": "primitive", "op": "read", - "args": {"path": "$t1.hits.0.file", "start": "$t1.hits.0.line"}}, - ]}) - journal = tmp_path / "j.jsonl" - out = run_plan(root, tmp_path, journal, Budget()) - assert out["status"] == "ok" and out["steps"] == 2 - report = verify(journal, tmp_path) - assert report["verdict"] == "PASS" and report["chain_ok"] is True - assert all(n["verdict"] == "replayed-match" for n in report["nodes"]) - - -def test_verify_detects_tampering(tmp_path): - (tmp_path / "a.py").write_text("VALUE = 42\n", encoding="utf-8") - root = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "read", "args": {"path": "a.py"}}, - ]}) - journal = tmp_path / "j.jsonl" - run_plan(root, tmp_path, journal, Budget()) - lines = journal.read_text(encoding="utf-8").splitlines() - rec = json.loads(lines[0]) - rec["args"]["path"] = "b.py" # tamper after the fact - journal.write_text(json.dumps(rec) + "\n", encoding="utf-8") - report = verify(journal, tmp_path) - assert report["verdict"] == "FAIL" and report["chain_ok"] is False - - -def test_verify_detects_artifact_change(tmp_path): - target = tmp_path / "out.txt" - root = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "write", "args": {"path": "out.txt", "content": "hello"}}, - ]}) - journal = tmp_path / "j.jsonl" - run_plan(root, tmp_path, journal, Budget()) - assert verify(journal, tmp_path)["verdict"] == "PASS" - target.write_text("changed\n", encoding="utf-8") - report = verify(journal, tmp_path) - assert report["verdict"] == "FAIL" - assert report["nodes"][0]["verdict"] == "artifact-MISMATCH" - - -def test_budget_stops_the_run(tmp_path): - (tmp_path / "a.py").write_text("X = 1\n", encoding="utf-8") - root = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "read", "args": {"path": "a.py"}}, - {"id": "t2", "kind": "primitive", "op": "read", "args": {"path": "a.py"}}, - ]}) - out = run_plan(root, tmp_path, tmp_path / "j.jsonl", Budget(max_steps=1)) - assert out["status"] == "budget" and out["steps"] == 1 - - -def test_journal_chain_links(tmp_path): - journal = Journal(tmp_path / "j.jsonl") - a = journal.append(node="t1", op="grep", args={"pattern": "x"}, status="ok", - result={"hits": []}, error=None, duration_ms=1) - b = journal.append(node="t2", op="grep", args={"pattern": "y"}, status="ok", - result={"hits": []}, error=None, duration_ms=1) - assert b["prev"] == a["hash"] and b["seq"] == 2 - -# ---------------------------------------------------------------- ephemeral brain - -import json as _json -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -import brain as brain_mod - - -class _Stub(BaseHTTPRequestHandler): - reply = {"args": {"pattern": "ActionReceipt"}} - last_chars = 0 - - def do_POST(self): # noqa: N802 - length = int(self.headers.get("Content-Length", 0)) - body = _json.loads(self.rfile.read(length)) - chars = sum(len(m.get("content") or "") for m in body.get("messages", [])) - type(self).last_chars = chars - payload = _json.dumps({ - "choices": [{"message": {"content": _json.dumps(type(self).reply)}}], - "usage": {"prompt_tokens": chars // 4, "completion_tokens": 12}, - }).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def log_message(self, *args): # silence - pass - - -@pytest.fixture() -def stub(): - server = ThreadingHTTPServer(("127.0.0.1", 0), _Stub) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - yield f"http://127.0.0.1:{server.server_port}/v1", _Stub - server.shutdown() - - -def test_single_action_prompt_is_small(): - probe = brain_mod.probe_offline(op="grep", goal="найди ActionReceipt") - assert probe["system_tokens_est"] < 80 - assert probe["total_tokens_est"] < 200 - - -def test_one_call_returns_args(stub): - endpoint, handler = stub - args, rec = brain_mod.decide(endpoint=endpoint, model="stub", node_id="t1", op="grep", - goal="найди ActionReceipt", last_digest="") - assert args == {"pattern": "ActionReceipt"} - assert rec.error is None - assert rec.prompt_tokens_est < 200 - assert rec.completion_tokens == 12 - - -def test_one_call_rejects_unknown_args(stub): - endpoint, handler = stub - handler.reply = {"args": {"zzz": 1}} - args, rec = brain_mod.decide(endpoint=endpoint, model="stub", node_id="t1", op="grep", - goal="x", last_digest="") - assert args == {} - assert "unknown arguments" in (rec.error or "") - handler.reply = {"args": {"pattern": "ActionReceipt"}} - - -def test_run_with_decider_makes_one_call_per_empty_node(tmp_path, stub): - endpoint, _ = stub - (tmp_path / "a.py").write_text("ActionReceipt = 1\n", encoding="utf-8") - root = from_dict({"id": "root", "kind": "compound", "children": [ - {"id": "t1", "kind": "primitive", "op": "grep", "args": {}}, - {"id": "t2", "kind": "primitive", "op": "read", - "args": {"path": "$t1.hits.0.file", "start": "$t1.hits.0.line"}}, - ]}) - calls = [] - - def decider(node, last_digest): - calls.append(node.id) - return brain_mod.decide(endpoint=endpoint, model="stub", node_id=node.id, op=node.op, - goal="найди ActionReceipt", last_digest=last_digest) - - out = run_plan(root, tmp_path, tmp_path / "j.jsonl", Budget(), decider=decider) - assert out["status"] == "ok" and out["steps"] == 2 - assert calls == ["t1"] # exactly one model call, for the only undecided node - assert len(out["brain_calls"]) == 1 - assert verify(tmp_path / "j.jsonl", tmp_path)["verdict"] == "PASS" From 7507b903d7d891eaf960d71bc95603bc74186a0a Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 22:11:12 +0300 Subject: [PATCH 09/12] test(microtask): make the acceptance fixture importable The temporary project used a namespace-package src/ that can be shadowed by the installed package on CI. Add an explicit src/__init__.py so the acceptance command tests the edit, not an import accident. --- tests/test_microtask_run_step.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_microtask_run_step.py b/tests/test_microtask_run_step.py index 9ccfe0d1..361f8313 100644 --- a/tests/test_microtask_run_step.py +++ b/tests/test_microtask_run_step.py @@ -34,6 +34,7 @@ def _project(tmp_path: Path) -> Path: project = tmp_path / "repo" (project / "src").mkdir(parents=True) (project / "tests").mkdir() + (project / "src" / "__init__.py").write_text("", encoding="utf-8") (project / "src" / "mod.py").write_text("def f():\n return 1\n", encoding="utf-8") (project / "tests" / "test_mod.py").write_text( "from src.mod import f\n\n\ndef test_f():\n assert f() == 2\n", encoding="utf-8" From a0ba261be4405545d0cd8bbc5e2b7691107f96bf Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 22:17:39 +0300 Subject: [PATCH 10/12] fix(microtask): pin acceptance cwd on PYTHONPATH The acceptance command runs inside a temporary project whose src/ may be shadowed by the installed package. Put the project directory first on PYTHONPATH so the edited file is the one under test. --- tools/microtask_pipeline/run_step.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/microtask_pipeline/run_step.py b/tools/microtask_pipeline/run_step.py index e5b821b0..78cade87 100644 --- a/tools/microtask_pipeline/run_step.py +++ b/tools/microtask_pipeline/run_step.py @@ -81,6 +81,11 @@ def apply_replacement(text: str, old: str, new: str) -> tuple[str | None, str]: def run_acceptance(cmd: str, cwd: Path) -> tuple[int, str]: if not cmd: return 0, "" + env = dict(os.environ) + prefix = str(cwd) + if env.get("PYTHONPATH"): + prefix += os.pathsep + env["PYTHONPATH"] + env["PYTHONPATH"] = prefix proc = subprocess.run( cmd, cwd=str(cwd), @@ -90,6 +95,7 @@ def run_acceptance(cmd: str, cwd: Path) -> tuple[int, str]: encoding="utf-8", errors="replace", timeout=900, + env=env, ) return proc.returncode, ((proc.stdout or "") + (proc.stderr or ""))[-800:] From a9c0764f5ed6051684dc013e04f323899d0fbfe2 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 22:23:27 +0300 Subject: [PATCH 11/12] test(microtask): pin the temporary project on sys.path A conftest in the fixture keeps the local src package ahead of the installed one on CI, so the acceptance command validates the edited file rather than an import accident. --- tests/test_microtask_run_step.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_microtask_run_step.py b/tests/test_microtask_run_step.py index 361f8313..0bc427ee 100644 --- a/tests/test_microtask_run_step.py +++ b/tests/test_microtask_run_step.py @@ -36,6 +36,11 @@ def _project(tmp_path: Path) -> Path: (project / "tests").mkdir() (project / "src" / "__init__.py").write_text("", encoding="utf-8") (project / "src" / "mod.py").write_text("def f():\n return 1\n", encoding="utf-8") + (project / "tests" / "conftest.py").write_text( + "import sys\nfrom pathlib import Path\n" + "sys.path.insert(0, str(Path(__file__).resolve().parents[1]))\n", + encoding="utf-8", + ) (project / "tests" / "test_mod.py").write_text( "from src.mod import f\n\n\ndef test_f():\n assert f() == 2\n", encoding="utf-8" ) From 4861055aaba2391e4c86e4ee67860ba61826c6f5 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 25 Oct 2026 22:29:20 +0300 Subject: [PATCH 12/12] test(microtask): use a direct import probe as acceptance pytest in the clean-state job resolves the installed src package before the temporary fixture. The acceptance command now imports the edited module directly from the project root, which is what the test is about. --- tests/test_microtask_run_step.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_microtask_run_step.py b/tests/test_microtask_run_step.py index 0bc427ee..c18db5bf 100644 --- a/tests/test_microtask_run_step.py +++ b/tests/test_microtask_run_step.py @@ -72,7 +72,7 @@ def test_apply_mode_edits_and_verifies(tmp_path): "filePath": str(project / "src" / "mod.py"), "oldString": "return 1", "newString": "return 2", - "acceptance": "python -m pytest tests/test_mod.py -q", + "acceptance": "python -c \"import sys; sys.path.insert(0, '.'); from src.mod import f; assert f() == 2\"", }, ) assert result.returncode == 0, result.stdout + result.stderr @@ -89,7 +89,7 @@ def test_apply_mode_reports_a_failing_acceptance(tmp_path): "filePath": str(project / "src" / "mod.py"), "oldString": "return 1", "newString": "return 3", - "acceptance": "python -m pytest tests/test_mod.py -q", + "acceptance": "python -c \"import sys; sys.path.insert(0, '.'); from src.mod import f; assert f() == 2\"", }, ) assert result.returncode == 1 @@ -106,7 +106,7 @@ def test_test_files_are_refused(tmp_path): "filePath": str(project / "tests" / "test_mod.py"), "oldString": "assert f() == 2", "newString": "assert True", - "acceptance": "python -m pytest tests/test_mod.py -q", + "acceptance": "python -c \"import sys; sys.path.insert(0, '.'); from src.mod import f; assert f() == 2\"", }, ) assert result.returncode == 1