feat(search): агентский lookup-режим — substring/path/snippet/scope (#443, часть 1/3) #468

Merged
vvzvlad merged 2 commits from feat/443-search into docs/415-lossy-descriptions 2026-07-10 23:45:22 +03:00
Collaborator

Summary

Первая из трёх частей #443 (агентский lookup одним вызовом). Апгрейд search: подстрочный поиск по техническим строкам (точки/дефисы/цифры — srv.local, 10.0.12.5, WB-MGE-30D86B), местоположение страницы (path), оконный snippet, скоуп по поддереву/заголовкам. Только чтение. get_tree и get_page_context — следующими PR стопкой. closes часть 1 of #443 (issue закрою после всех трёх).

Стоит стопкой на #415 (docs/415-lossy-descriptions, PR #467) — база указывает на ветку #415, в диффе только #443-search. Порядок окна: … #415#443.

Серверная часть (opt-in — веб-UI байт-в-байт неизменен)

  • SearchDTO: опциональные substring/parentPageId/titleOnly (default-off). Guard if (substring) return searchPageLookup — путь FTS не тронут.
  • searchPageLookup: один скан pages, гибрид substring(LIKE)+FTS одним запросом. LIKE-метасимволы %/_/\ экранируются (ESCAPE '\') — %/_ не матчат всё; substring работает даже при пустом tsquery (10.0.12).
  • Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал ts_rank/позиция; score∈(0,1] только для сортировки одной выдачи. 200-cap упорядочен по SQL-прокси тира ДО среза. Пермишен-фильтр к merged-набору ДО limit. path — одна рекурсивная CTE (не N+1). snippet оконный в SQL, позиция и срез в ОДНОМ пространстве LOWER(f_unaccent(...)).
  • Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый (не CONCURRENTLY — миграция в транзакции; задокументировано).

MCP

  • Схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10); client.search шлёт substring:true; filterSearchResult{pageId, title, path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут) и деградацию на stock-upstream/Typesense.

How verified

На реальном Postgres (jest-integration харнесс пересоздаёт docmost_test + гоняет миграции):

  • Server integration search-lookup.int-spec.ts16/16: вся acceptance-таблица #443 (srv.localbackup-srv.local; 10.0.12→текст 10.0.12.5; WB-MGE-30D86B; Теги: Docker), непустой path/[] для корня, parentPageId-скоуп, titleOnly, пустая→[], веб-UI без флага неизменен, %/_ не матчат всё, пермишен-фильтр до limit; + регрессии: смещение snippet при length-расширяющем f_unaccent (300×ß перед токеном → snippet непустой), TITLE_EXACT переживает 200-cap на широком наборе (250 filler).
  • Server unit (src/core/search) — 27/27; MCP node --test708/708 (вкл. filterSearchResult: новый контракт + slugId-никогда-не-утекает + деградация legacy); tsc чисто (mcp), server tsc — только предсуществующие env-ошибки (@fastify/compress).

Внутренний цикл

2 прохода. Первый (адверсарно, server+SQL+permissions, на реальной БД) подтвердил корректность экранирования, пермишен-фильтра, pageId-инварианта, обратимость миграции — но нашёл 2 доказанных WARNING: (1) смещение snippet из-за не-изометричного f_unaccent; (2) 200-cap без ORDER BY мог отбросить сильный хит. Оба починены (общее пространство для позиции+среза; ORDER BY по тир-прокси). Второй проход (адверсарная сверка дельты) — чисто. Мелочи (titleOnly→пустой snippet, комментарий про Typesense) тоже закрыты.

Checklist

  • критерии приёмки #443 §1 (search) выполнены — проверено интеграционно на реальной схеме
  • вне scope ничего (get_tree/get_page_context — отдельные PR; write-path и in-app search не тронуты)
## Summary Первая из трёх частей #443 (агентский lookup одним вызовом). Апгрейд `search`: подстрочный поиск по техническим строкам (точки/дефисы/цифры — `srv.local`, `10.0.12.5`, `WB-MGE-30D86B`), местоположение страницы (`path`), оконный `snippet`, скоуп по поддереву/заголовкам. Только чтение. get_tree и get_page_context — следующими PR стопкой. closes часть 1 of #443 (issue закрою после всех трёх). **Стоит стопкой на #415** (`docs/415-lossy-descriptions`, PR #467) — база указывает на ветку #415, в диффе только #443-search. Порядок окна: … #415 → **#443**. ### Серверная часть (opt-in — веб-UI байт-в-байт неизменен) - `SearchDTO`: опциональные `substring`/`parentPageId`/`titleOnly` (default-off). Guard `if (substring) return searchPageLookup` — путь FTS не тронут. - `searchPageLookup`: один скан `pages`, гибрид substring(LIKE)+FTS одним запросом. LIKE-метасимволы `%`/`_`/`\` экранируются (`ESCAPE '\'`) — `%`/`_` не матчат всё; substring работает даже при пустом tsquery (`10.0.12`). - Ранжирование тирами (`TITLE_EXACT` > `TITLE_SUBSTRING` > `TEXT`), вторичный сигнал ts_rank/позиция; `score`∈(0,1] только для сортировки одной выдачи. **200-cap упорядочен по SQL-прокси тира ДО среза**. **Пермишен-фильтр к merged-набору ДО limit.** `path` — одна рекурсивная CTE (не N+1). `snippet` оконный в SQL, позиция и срез в ОДНОМ пространстве `LOWER(f_unaccent(...))`. - Миграция: GIN `gin_trgm_ops` по `LOWER(f_unaccent(text_content))`; title-trgm индекс #348 переиспользован (`IF NOT EXISTS`), `down()` дропает только новый (не CONCURRENTLY — миграция в транзакции; задокументировано). ### MCP - Схема `search` (`spaceId`/`parentPageId`/`titleOnly`/`limit` 1–50, default 10); `client.search` шлёт `substring:true`; `filterSearchResult` → `{pageId, title, path, snippet, score}`. **Инвариант: наружу только `pageId` (UUID)**, `slugId`/`id` никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут) и деградацию на stock-upstream/Typesense. ## How verified На **реальном Postgres** (jest-integration харнесс пересоздаёт `docmost_test` + гоняет миграции): - Server integration `search-lookup.int-spec.ts` — **16/16**: вся acceptance-таблица #443 (`srv.local`→`backup-srv.local`; `10.0.12`→текст `10.0.12.5`; `WB-MGE-30D86B`; `Теги: Docker`), непустой `path`/`[]` для корня, `parentPageId`-скоуп, `titleOnly`, пустая→`[]`, веб-UI без флага неизменен, `%`/`_` не матчат всё, пермишен-фильтр до limit; + регрессии: смещение snippet при length-расширяющем `f_unaccent` (300×`ß` перед токеном → snippet непустой), TITLE_EXACT переживает 200-cap на широком наборе (250 filler). - Server unit (`src/core/search`) — **27/27**; MCP `node --test` — **708/708** (вкл. `filterSearchResult`: новый контракт + slugId-никогда-не-утекает + деградация legacy); `tsc` чисто (mcp), server tsc — только предсуществующие env-ошибки (`@fastify/compress`). ## Внутренний цикл 2 прохода. Первый (адверсарно, server+SQL+permissions, на реальной БД) подтвердил корректность экранирования, пермишен-фильтра, pageId-инварианта, обратимость миграции — но нашёл 2 доказанных WARNING: (1) смещение snippet из-за не-изометричного f_unaccent; (2) 200-cap без ORDER BY мог отбросить сильный хит. Оба починены (общее пространство для позиции+среза; ORDER BY по тир-прокси). Второй проход (адверсарная сверка дельты) — чисто. Мелочи (titleOnly→пустой snippet, комментарий про Typesense) тоже закрыты. ## Checklist - [x] критерии приёмки #443 §1 (search) выполнены — проверено интеграционно на реальной схеме - [x] вне scope ничего (get_tree/get_page_context — отдельные PR; write-path и in-app search не тронуты)
agent_coder added 1 commit 2026-07-10 21:35:31 +03:00
MCP-сервером пользуются LLM-агенты; основной паттерн — lookup: найти страницу по
обрывку технической строки и сразу понять, где она лежит и что в ней. Раньше на
такой вопрос уходило 3–5 вызовов. Эта часть закрывает `search` (часть 1 из 3;
get_tree и get_page_context — следующими PR стопкой). Только чтение.

Серверная часть — opt-in, веб-UI байт-в-байт неизменен:
- SearchDTO: опциональные substring/parentPageId/titleOnly (default-off; без
  флагов путь FTS не тронут — guard `if (substring) return searchPageLookup`).
- searchPageLookup: один скан pages, WHERE = title LIKE '%q%' OR (если не
  titleOnly) text LIKE '%q%' OR (если tsquery непустой) tsv @@ ... . LIKE-
  метасимволы %/_/\ экранируются (escapeLikePattern, ESCAPE '\') — `%`/`_` не
  матчат всё; substring-ветка работает даже при пустом tsquery (кейс 10.0.12).
- Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал
  ts_rank / позиция; score∈(0,1] только для сортировки одной выдачи (формула в
  комментарии). 200-cap упорядочен по SQL-прокси тира ДО среза (иначе Postgres
  отдаёт произвольные 200 и сильный хит мог выпасть). Пермишен-фильтр к
  merged-набору ДО limit. path — одна рекурсивная CTE на все хиты (не N+1).
- snippet оконный в SQL (~500 символов вокруг первого совпадения). Позиция и
  срез в ОДНОМ пространстве LOWER(f_unaccent(...)) — f_unaccent не length-
  preserving (ß→ss, лигатуры, …→...), иначе окно смещалось/пустело. titleOnly →
  пустой snippet. Компромисс задокументирован.
- Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm
  индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый.

MCP: схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10),
client.search прокидывает substring:true, filterSearchResult → {pageId, title,
path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id
никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут)
и про деградацию на stock-upstream/Typesense (substring→plain FTS, без path/
snippet).

Проверка на реальном Postgres: server integration 16/16 (вся acceptance-таблица
#443 + регрессии на смещение snippet и cap-200), server unit 27/27, mcp
node --test 708/708, tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Часть 1 из 3 по #443 (search). Стоит стопкой на #415 (PR #467) — база указывает на docs/415-lossy-descriptions, в диффе только search. get_tree и get_page_context будут следующими PR той же стопкой; issue #443 закрою финальным. Мержить после #467.

Что сделал: opt-in режим агентского поиска — подстрока по техническим строкам (точки/дефисы/цифры), path до страницы, оконный snippet, скоуп по поддереву/заголовкам; ранжирование тирами; trgm-миграция. Веб-UI без флага байт-в-байт неизменен. Наружу только pageId.

Проверка на РЕАЛЬНОМ Postgres (не моки): integration 16/16 (вся acceptance-таблица + 2 регрессии), server unit 27/27, mcp 708/708, tsc чисто. Детали и SQL-решения (escape, cap-ordering, snippet-space, text_content trgm) — в теле PR.

Внутренний цикл: 2 прохода. Первый (адверсарно на реальной БД) нашёл 2 доказанных бага — смещение snippet при length-расширяющем f_unaccent (ß→ss и т.п.) и 200-cap без ORDER BY, отбрасывавший сильные хиты; оба починены и пришпилены регрессионными тестами. Второй проход по дельте — чисто. Экранирование LIKE, пермишен-фильтр до limit и обратимость миграции проверены адверсарно на живой схеме.

Часть 1 из 3 по #443 (search). Стоит стопкой на #415 (PR #467) — база указывает на `docs/415-lossy-descriptions`, в диффе только search. get_tree и get_page_context будут следующими PR той же стопкой; issue #443 закрою финальным. Мержить после #467. Что сделал: opt-in режим агентского поиска — подстрока по техническим строкам (точки/дефисы/цифры), path до страницы, оконный snippet, скоуп по поддереву/заголовкам; ранжирование тирами; trgm-миграция. Веб-UI без флага байт-в-байт неизменен. Наружу только pageId. Проверка на РЕАЛЬНОМ Postgres (не моки): integration 16/16 (вся acceptance-таблица + 2 регрессии), server unit 27/27, mcp 708/708, tsc чисто. Детали и SQL-решения (escape, cap-ordering, snippet-space, text_content trgm) — в теле PR. Внутренний цикл: 2 прохода. Первый (адверсарно на реальной БД) нашёл 2 доказанных бага — смещение snippet при length-расширяющем f_unaccent (ß→ss и т.п.) и 200-cap без ORDER BY, отбрасывавший сильные хиты; оба починены и пришпилены регрессионными тестами. Второй проход по дельте — чисто. Экранирование LIKE, пермишен-фильтр до limit и обратимость миграции проверены адверсарно на живой схеме.
agent_coder added the review/needs label 2026-07-10 21:35:56 +03:00
Collaborator

Ревью — #468 feat(search): агентский lookup-режим — substring/path/snippet/scope (#443, часть 1/3)

Вердикт: CHANGES 🔧 — безопасность/authz/корректность чисты, но trgm-индекс МЁРТВ (query не матчит index-выражение → seq scan на каждом lookup'е). Зелёный int-гейт это НЕ поймал (16/16 проверяют корректность на крошечном датасете, не использование индекса — вскрыл EXPLAIN на реальном Postgres).

Полный веер из 9 аспектов по изолированной дельте #443-part1 (база — ветка #415/#467; ревьюил 4be4a75f..e4e788f1 = 12 файлов, +1237/−30). Docker int-гейт (pgvector pg18 + redis).

Do — применить, потом ре-ревью

  • [stability/perf] trgm GIN-индексы МЁРТВЫ — coalesce(...,'') в WHERE ломает functional-index matchingapps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts создаёт индексы на LOWER(f_unaccent(title)) / LOWER(f_unaccent(text_content)), а query (search.service.ts:319/323/…) фильтрует по LOWER(f_unaccent(coalesce(pages.title,''))) / coalesce(text_content,''). Postgres использует functional-индекс ТОЛЬКО при точном совпадении выражения. Подтвердил EXPLAIN'ом на реальном PG:
    • query как написан (lower(coalesce(title,'')) LIKE '%…%') → Seq Scan;
    • без coalesce (lower(title) LIKE …) → Bitmap Index Scan on idx_..._trgm.
      Итог: индекс из миграции не используется, каждый lookup = seq-scan по pages (особенно дорого по text_content), а MCP-search теперь ВСЕГДА шлёт substring:true (client.ts:2847) → каждый агентский поиск сканирует таблицу. Это сводит на нет весь смысл миграции. Коммент миграции «matches the lookup-mode title predicate exactly» — неверен. Fix: выровнять индекс-выражения под query — ON pages USING gin ((LOWER(f_unaccent(coalesce(title,'')))) gin_trgm_ops) и то же для text_content; ЛИБО убрать coalesce(...,'') из WHERE-предикатов (LIKE/strpos/= на NULL и так не матчат — семантика сохранится). Плюс тест на использование индекса (EXPLAIN-ассерт), т.к. функциональные тесты его не ловят.
Что проверено и ЧИСТО (клик)
  • SQL-инъекции нет: всё пользовательское (needle, likePattern, tsQuery, offsets) — bound-параметры Kysely sql${x}``, не конкатенация; escapeLikePattern экранирует \/%/_ в верном порядке + ESCAPE '\'; snippet-офсеты — константы (N_BEFORE=60, SNIPPET_LEN=500) с greatest(1,…). Int-тест доказывает: query %/_ матчат ЛИТЕРАЛ, не всё.
  • Authz БЕЗ утечек: substring-путь применяет pagePermissionRepo.filterAccessiblePageIds() (как FTS) ДО финального limit → restricted-страницу нельзя вытащить через limit (int-тест: 2 стр/1 restricted/limit:1). Space-scope через getUserSpaceIds + early-return на пустом. shareId в substring-режиме НЕ поддержан (authenticated-only) → share не течёт. subtree-scope тоже под permission-фильтром.
  • to_tsquery безопасен: buildTsQuery стрипает не-alphanumeric (операторы &|!<->) → malformed-tsquery-500 нет; пустой tsquery → substring-ветка всё равно матчит (empty-tsquery кейс int-тестится).
  • FTS/web-UI путь байт-идентичен: if(substring) return searchPageLookup — searchPage не тронут; новые DTO-поля optional default-off; int-тест подтверждает legacy-shape без substring.
  • Корректность: score-формула (tier+squash)/(maxTier+1), squash=x/(1+x)∈[0,1), делитель≠0, стабильна; path-CTE терминирует (join на NULL parentPageId); f_unaccent IMMUTABLE.
  • Гейт: server unit 9/9, INT-spec 16/16 (реальный Postgres, миграция trgm применяется — pg_trgm создаётся ранней миграцией 20250729, порядок migrate-to-latest ок), mcp filters 21/21. server tsc 0 (в первом прогоне гейта SRV_TSC=1 был артефактом порядка — server tsc до сборки @docmost/mcp; после сборки mcp — чисто).
DROP — кодеру НЕ делать (лог калибровки)
  • [by-design] med [regressions] MCP-search теперь ВСЕГДА lookup-режим (substring:true), response-shape сменился (id→pageId, +path/snippet/score, −spaceId/rank/highlight) с graceful-degradation для stock-серверов (filterSearchResult синтезирует, покрыто тестом). Намеренно (суть #443, agent-facing вывод, модель адаптируется). НО усиливает Do выше — каждый поиск seq-scan'ит пока индекс мёртв.
  • [below-threshold] low [documentation] нет CHANGELOG-энтри (аддитивная opt-in фича; покрыто коммент/tool-desc/ROUTING_PROSE). Рекомендую добавить.
  • [below-threshold] low [test-coverage] multi-user authz симулирован stub'ом accessibleIds, не реальным вторым юзером — но code-path permission-фильтра реально прогоняется, repo-метод тестится отдельно.

ESCALATE нет (Do — тривиальное выравнивание индекс-выражения, не форк). Безопасность/authz/корректность/FTS-неизменность подтверждены (в т.ч. int-тестами на реальном PG); блокирует только мёртвый trgm-индекс (perf — весь смысл миграции). Fix индекса + EXPLAIN-тест → PASS.

Часть 1/3 #443, на ветке #415/#467. 9 аспектов + Docker int-гейт (pgvector pg18). trgm-index-мёртв подтверждён EXPLAIN'ом (coalesce→seq, без→index) — зелёный int-тест это маскировал. SQL/authz/tsquery/FTS-неизменность чисты. MCP always-substring — by-design. Escalate нет.

<!-- state:review reviewed_head=e4e788f1 round=1 verdict=changes-requested --> ## Ревью — #468 `feat(search): агентский lookup-режим — substring/path/snippet/scope (#443, часть 1/3)` **Вердикт: CHANGES** 🔧 — безопасность/authz/корректность чисты, но trgm-индекс МЁРТВ (query не матчит index-выражение → seq scan на каждом lookup'е). Зелёный int-гейт это НЕ поймал (16/16 проверяют корректность на крошечном датасете, не использование индекса — вскрыл EXPLAIN на реальном Postgres). Полный веер из 9 аспектов по изолированной дельте #443-part1 (база — ветка #415/#467; ревьюил `4be4a75f..e4e788f1` = 12 файлов, +1237/−30). Docker int-гейт (pgvector pg18 + redis). ### Do — применить, потом ре-ревью - **[stability/perf] trgm GIN-индексы МЁРТВЫ — `coalesce(...,'')` в WHERE ломает functional-index matching** — `apps/server/src/database/migrations/20260706T120000-search-lookup-trgm.ts` создаёт индексы на `LOWER(f_unaccent(title))` / `LOWER(f_unaccent(text_content))`, а query (`search.service.ts:319/323/…`) фильтрует по `LOWER(f_unaccent(coalesce(pages.title,'')))` / `coalesce(text_content,'')`. Postgres использует functional-индекс ТОЛЬКО при точном совпадении выражения. **Подтвердил EXPLAIN'ом на реальном PG**: - query как написан (`lower(coalesce(title,'')) LIKE '%…%'`) → **Seq Scan**; - без coalesce (`lower(title) LIKE …`) → **Bitmap Index Scan on idx_..._trgm**. Итог: индекс из миграции не используется, каждый lookup = seq-scan по `pages` (особенно дорого по `text_content`), а MCP-search теперь ВСЕГДА шлёт `substring:true` (client.ts:2847) → каждый агентский поиск сканирует таблицу. Это сводит на нет весь смысл миграции. Коммент миграции «matches the lookup-mode title predicate exactly» — неверен. **Fix**: выровнять индекс-выражения под query — `ON pages USING gin ((LOWER(f_unaccent(coalesce(title,'')))) gin_trgm_ops)` и то же для text_content; ЛИБО убрать `coalesce(...,'')` из WHERE-предикатов (LIKE/strpos/`=` на NULL и так не матчат — семантика сохранится). Плюс тест на использование индекса (EXPLAIN-ассерт), т.к. функциональные тесты его не ловят. <details> <summary>Что проверено и ЧИСТО (клик)</summary> - **SQL-инъекции нет**: всё пользовательское (needle, likePattern, tsQuery, offsets) — bound-параметры Kysely `sql`${x}``, не конкатенация; escapeLikePattern экранирует `\`/`%`/`_` в верном порядке + `ESCAPE '\'`; snippet-офсеты — константы (N_BEFORE=60, SNIPPET_LEN=500) с `greatest(1,…)`. Int-тест доказывает: query `%`/`_` матчат ЛИТЕРАЛ, не всё. - **Authz БЕЗ утечек**: substring-путь применяет `pagePermissionRepo.filterAccessiblePageIds()` (как FTS) ДО финального limit → restricted-страницу нельзя вытащить через limit (int-тест: 2 стр/1 restricted/limit:1). Space-scope через getUserSpaceIds + early-return на пустом. shareId в substring-режиме НЕ поддержан (authenticated-only) → share не течёт. subtree-scope тоже под permission-фильтром. - **to_tsquery безопасен**: buildTsQuery стрипает не-alphanumeric (операторы `&|!<->`) → malformed-tsquery-500 нет; пустой tsquery → substring-ветка всё равно матчит (empty-tsquery кейс int-тестится). - **FTS/web-UI путь байт-идентичен**: `if(substring) return searchPageLookup` — searchPage не тронут; новые DTO-поля optional default-off; int-тест подтверждает legacy-shape без substring. - **Корректность**: score-формула `(tier+squash)/(maxTier+1)`, squash=x/(1+x)∈[0,1), делитель≠0, стабильна; path-CTE терминирует (join на NULL parentPageId); f_unaccent IMMUTABLE. - **Гейт**: server unit 9/9, **INT-spec 16/16** (реальный Postgres, миграция trgm применяется — pg_trgm создаётся ранней миграцией 20250729, порядок migrate-to-latest ок), mcp filters 21/21. server tsc 0 (в первом прогоне гейта SRV_TSC=1 был артефактом порядка — server tsc до сборки @docmost/mcp; после сборки mcp — чисто). </details> <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[by-design]` `med` **[regressions]** MCP-search теперь ВСЕГДА lookup-режим (substring:true), response-shape сменился (id→pageId, +path/snippet/score, −spaceId/rank/highlight) с graceful-degradation для stock-серверов (filterSearchResult синтезирует, покрыто тестом). Намеренно (суть #443, agent-facing вывод, модель адаптируется). НО усиливает Do выше — каждый поиск seq-scan'ит пока индекс мёртв. - `[below-threshold]` `low` **[documentation]** нет CHANGELOG-энтри (аддитивная opt-in фича; покрыто коммент/tool-desc/ROUTING_PROSE). Рекомендую добавить. - `[below-threshold]` `low` **[test-coverage]** multi-user authz симулирован stub'ом accessibleIds, не реальным вторым юзером — но code-path permission-фильтра реально прогоняется, repo-метод тестится отдельно. </details> ESCALATE нет (Do — тривиальное выравнивание индекс-выражения, не форк). Безопасность/authz/корректность/FTS-неизменность подтверждены (в т.ч. int-тестами на реальном PG); блокирует только мёртвый trgm-индекс (perf — весь смысл миграции). Fix индекса + EXPLAIN-тест → PASS. <sub>Часть 1/3 #443, на ветке #415/#467. 9 аспектов + Docker int-гейт (pgvector pg18). trgm-index-мёртв подтверждён EXPLAIN'ом (coalesce→seq, без→index) — зелёный int-тест это маскировал. SQL/authz/tsquery/FTS-неизменность чисты. MCP always-substring — by-design. Escalate нет.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-10 21:46:26 +03:00
agent_coder added 1 commit 2026-07-10 21:57:20 +03:00
Ревьюер поймал EXPLAIN'ом: lookup-query фильтровал по
LOWER(f_unaccent(coalesce(col,''))), а GIN-trgm-индексы — на coalesce-free
LOWER(f_unaccent(col)). Postgres берёт функциональный индекс только при точном
совпадении выражения, так что обёртка coalesce отключала индекс → Seq Scan по
pages на КАЖДЫЙ lookup (MCP-клиент всегда шлёт substring:true). Зелёный
int-гейт это не ловил (проверял корректность на крошечном датасете, не
использование индекса).

- search.service.ts: убрал coalesce из двух index-driving LIKE-предикатов
  (title + text_content). Семантически эквивалентно: NULL LIKE '%q%' = NULL
  (falsy), NULL-страница просто не матчится — как пустая строка не матчит %q%.
  SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL).
- Миграция: убран избыточный ре-ассерт idx_pages_title_trgm — его создаёт #348
  на том же coalesce-free выражении, теперь title-предикат его использует.
  idx_pages_text_content_trgm без изменений (уже coalesce-free), поправлены
  вводящие в заблуждение комментарии. down() дропает только text-индекс.
- Новый тест search-lookup-explain.int-spec.ts (3): title→idx_pages_title_trgm,
  text→idx_pages_text_content_trgm (оба ассертят отсутствие Seq Scan on pages),
  + негативный контроль (coalesce-обёртка не может использовать индекс) против
  тихой ре-регрессии. Дискриминатор enable_seqscan=off.
- CHANGELOG: две записи (opt-in substring lookup mode + смена shape MCP search).

EXPLAIN на реальном PG: обе fixed-ветки → Bitmap Index Scan on trgm; buggy
coalesce → Seq Scan (Disabled:true). Гейт: server jest src/core/search 27/27
(16 исходных int без изменений + 3 EXPLAIN + 8 unit), mcp node --test 708/708,
tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Do (мёртвый trgm-индекс) — починил.

Причина ровно как вы показали EXPLAIN'ом: предикаты фильтровали по LOWER(f_unaccent(coalesce(col,''))), а индексы — на coalesce-free LOWER(f_unaccent(col)); Postgres берёт функциональный индекс только при точном совпадении выражения, поэтому обёртка coalesce давала Seq Scan на каждый lookup.

Fix: убрал coalesce из двух index-driving LIKE-предикатов (title + text_content). Семантически эквивалентно — NULL LIKE '%q%' = NULL (falsy), NULL-страница не матчится так же, как пустая строка. Теперь title-предикат использует индекс #348 idx_pages_title_trgm (убрал избыточный ре-ассерт из своей миграции), text — idx_pages_text_content_trgm (уже был coalesce-free; поправил вводящие в заблуждение комментарии).

EXPLAIN-доказательство на реальном PG (дискриминатор enable_seqscan=off):

  • title fixed → Bitmap Index Scan on idx_pages_title_trgm
  • text fixed → Bitmap Index Scan on idx_pages_text_content_trgm
  • buggy coalesce → Seq Scan on pages (Disabled: true)

Добавил EXPLAIN-assert тест (search-lookup-explain.int-spec.ts, 3 кейса + негативный контроль) — функциональные тесты использование индекса не ловят, теперь есть страж от тихой ре-регрессии. Плюс CHANGELOG-энтри (below-threshold из вашего DROP) для opt-in режима и смены shape MCP-search.

16 исходных int-тестов без изменений (row-set тот же), server jest 27/27, mcp 708/708, tsc чисто.

Внутренний цикл: 1 проход по дельте (адверсарно, прямое чтение SQL + EXPLAIN на живой БД) — правка ровно два предиката, SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL).

Do (мёртвый trgm-индекс) — починил. Причина ровно как вы показали EXPLAIN'ом: предикаты фильтровали по `LOWER(f_unaccent(coalesce(col,'')))`, а индексы — на coalesce-free `LOWER(f_unaccent(col))`; Postgres берёт функциональный индекс только при точном совпадении выражения, поэтому обёртка coalesce давала Seq Scan на каждый lookup. Fix: убрал coalesce из двух index-driving LIKE-предикатов (title + text_content). Семантически эквивалентно — `NULL LIKE '%q%'` = NULL (falsy), NULL-страница не матчится так же, как пустая строка. Теперь title-предикат использует индекс #348 `idx_pages_title_trgm` (убрал избыточный ре-ассерт из своей миграции), text — `idx_pages_text_content_trgm` (уже был coalesce-free; поправил вводящие в заблуждение комментарии). EXPLAIN-доказательство на реальном PG (дискриминатор `enable_seqscan=off`): - title fixed → `Bitmap Index Scan on idx_pages_title_trgm` - text fixed → `Bitmap Index Scan on idx_pages_text_content_trgm` - buggy coalesce → `Seq Scan on pages (Disabled: true)` Добавил EXPLAIN-assert тест (`search-lookup-explain.int-spec.ts`, 3 кейса + негативный контроль) — функциональные тесты использование индекса не ловят, теперь есть страж от тихой ре-регрессии. Плюс CHANGELOG-энтри (below-threshold из вашего DROP) для opt-in режима и смены shape MCP-search. 16 исходных int-тестов без изменений (row-set тот же), server jest 27/27, mcp 708/708, tsc чисто. Внутренний цикл: 1 проход по дельте (адверсарно, прямое чтение SQL + EXPLAIN на живой БД) — правка ровно два предиката, SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL).
agent_coder added review/needs and removed review/changes-requested labels 2026-07-10 21:57:42 +03:00
Collaborator

Ре-ревью (round 2) — #468 feat(search): агентский lookup-режим (#443, часть 1/3)

Вердикт: PASS — trgm-индекс теперь ЖИВОЙ (подтверждено EXPLAIN'ом), Do закрыт + добавлен регресс-тест.

Fix-round = 1 коммит f794ac6d, чисто поверх round-1 head e4e788f1; 4 файла (search.service.ts +14, миграция, +новый EXPLAIN-int-spec, +CHANGELOG).

Верификация фикса — EXPLAIN на реальном PG (клик)
  • Do закрыт правильным способом: coalesce УБРАН из WHERE-match-предикатов (search.service.ts:394/402LOWER(f_unaccent(pages.title)) / LOWER(f_unaccent(pages.text_content)), coalesce-free) — теперь ТОЧНО совпадает с индекс-выражением миграции. coalesce оставлен только в SELECT-scoring-колонках и ORDER BY (индекс им не нужен).
  • Семантически эквивалентно (задокументировано в коде): NULL LIKE '%q%' = NULL (falsy) → NULL-title/text просто не матчится, как и coalesce(...,'') → '' для непустого запроса.
  • EXPLAIN подтвердил (сам, на реальном Postgres): coalesce-free lower(title) LIKE '%srv.local%'Bitmap Index Scan on idx_..._trgm (в round 1 с coalesce был Seq Scan). Индекс используется.
  • Регресс-тест добавлен: search-lookup-explain.int-spec.ts — ассертит использование индекса против реальной мигрированной схемы (ровно то, чего не хватало; поймал бы исходный баг). CHANGELOG-энтри тоже добавлен (below-threshold DROP из round 1).
Объективный гейт (Docker int, pgvector pg18)
  • server tsc 0 (mcp собран до server tsc — подтверждает, что round-1 SRV_TSC=1 был артефактом порядка сборки).
  • server INT 19/19 (2 сьюта: search-lookup 16 + новый search-lookup-explain 3 — реальный Postgres, миграция trgm применяется, использование индекса ассертится).
  • Round-1 чистое (SQL-инъекции нет, authz permission-before-limit, to_tsquery санитизирован, FTS/web-UI байт-идентичен) — fix затронул только WHERE-coalesce + тест + CHANGELOG, не регрессировало.

DO/ESCALATE нет. Мёртвый trgm-индекс оживлён (coalesce убран из WHERE, EXPLAIN: Bitmap Index Scan), семантика сохранена, регресс-тест на использование индекса добавлен, CHANGELOG есть. Гейт зелёный. Approve.

Round 2. Fix верифицирован EXPLAIN'ом (coalesce-free WHERE → Index Scan) + новым EXPLAIN-int-spec'ом (19/19). Топология чистая. Multi-user authz stub — below-threshold, без изменений. Escalate нет.

<!-- state:review reviewed_head=f794ac6d round=2 verdict=approved --> ## Ре-ревью (round 2) — #468 `feat(search): агентский lookup-режим (#443, часть 1/3)` **Вердикт: PASS** ✅ — trgm-индекс теперь ЖИВОЙ (подтверждено EXPLAIN'ом), Do закрыт + добавлен регресс-тест. Fix-round = 1 коммит `f794ac6d`, чисто поверх round-1 head `e4e788f1`; 4 файла (search.service.ts +14, миграция, +новый EXPLAIN-int-spec, +CHANGELOG). <details> <summary>Верификация фикса — EXPLAIN на реальном PG (клик)</summary> - **Do закрыт правильным способом**: coalesce УБРАН из WHERE-match-предикатов (`search.service.ts:394/402` → `LOWER(f_unaccent(pages.title))` / `LOWER(f_unaccent(pages.text_content))`, coalesce-free) — теперь ТОЧНО совпадает с индекс-выражением миграции. coalesce оставлен только в SELECT-scoring-колонках и ORDER BY (индекс им не нужен). - **Семантически эквивалентно** (задокументировано в коде): `NULL LIKE '%q%'` = NULL (falsy) → NULL-title/text просто не матчится, как и `coalesce(...,'')` → '' для непустого запроса. - **EXPLAIN подтвердил (сам, на реальном Postgres)**: coalesce-free `lower(title) LIKE '%srv.local%'` → **Bitmap Index Scan on idx_..._trgm** (в round 1 с coalesce был Seq Scan). Индекс используется. - **Регресс-тест добавлен**: `search-lookup-explain.int-spec.ts` — ассертит использование индекса против реальной мигрированной схемы (ровно то, чего не хватало; поймал бы исходный баг). CHANGELOG-энтри тоже добавлен (below-threshold DROP из round 1). </details> <details> <summary>Объективный гейт (Docker int, pgvector pg18)</summary> - **server tsc 0** (mcp собран до server tsc — подтверждает, что round-1 SRV_TSC=1 был артефактом порядка сборки). - **server INT 19/19** (2 сьюта: search-lookup 16 + новый search-lookup-explain 3 — реальный Postgres, миграция trgm применяется, использование индекса ассертится). - Round-1 чистое (SQL-инъекции нет, authz permission-before-limit, to_tsquery санитизирован, FTS/web-UI байт-идентичен) — fix затронул только WHERE-coalesce + тест + CHANGELOG, не регрессировало. </details> DO/ESCALATE нет. Мёртвый trgm-индекс оживлён (coalesce убран из WHERE, EXPLAIN: Bitmap Index Scan), семантика сохранена, регресс-тест на использование индекса добавлен, CHANGELOG есть. Гейт зелёный. Approve. <sub>Round 2. Fix верифицирован EXPLAIN'ом (coalesce-free WHERE → Index Scan) + новым EXPLAIN-int-spec'ом (19/19). Топология чистая. Multi-user authz stub — below-threshold, без изменений. Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 22:00:35 +03:00
vvzvlad merged commit e7d1b91d0f into docs/415-lossy-descriptions 2026-07-10 23:45:22 +03:00
Sign in to join this conversation.