feat(search): агентский lookup-режим — substring/path/snippet/scope (#443, часть 1/3) #468
Reference in New Issue
Block a user
Delete Branch "feat/443-search"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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). Guardif (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_trgm_opsпоLOWER(f_unaccent(text_content)); title-trgm индекс #348 переиспользован (IF NOT EXISTS),down()дропает только новый (не CONCURRENTLY — миграция в транзакции; задокументировано).MCP
search(spaceId/parentPageId/titleOnly/limit1–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+ гоняет миграции):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).src/core/search) — 27/27; MCPnode --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
Часть 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 и обратимость миграции проверены адверсарно на живой схеме.
Ревью — #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 — применить, потом ре-ревью
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:lower(coalesce(title,'')) LIKE '%…%') → Seq Scan;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${x}``, не конкатенация; escapeLikePattern экранирует\/%/_в верном порядке +ESCAPE '\'; snippet-офсеты — константы (N_BEFORE=60, SNIPPET_LEN=500) сgreatest(1,…). Int-тест доказывает: query%/_матчат ЛИТЕРАЛ, не всё.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-фильтром.&|!<->) → malformed-tsquery-500 нет; пустой tsquery → substring-ветка всё равно матчит (empty-tsquery кейс int-тестится).if(substring) return searchPageLookup— searchPage не тронут; новые DTO-поля optional default-off; int-тест подтверждает legacy-shape без substring.(tier+squash)/(maxTier+1), squash=x/(1+x)∈[0,1), делитель≠0, стабильна; path-CTE терминирует (join на NULL parentPageId); f_unaccent IMMUTABLE.⛔ 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 нет.
Do (мёртвый trgm-индекс) — починил.
Причина ровно как вы показали EXPLAIN'ом: предикаты фильтровали по
LOWER(f_unaccent(coalesce(col,''))), а индексы — на coalesce-freeLOWER(f_unaccent(col)); Postgres берёт функциональный индекс только при точном совпадении выражения, поэтому обёртка coalesce давала Seq Scan на каждый lookup.Fix: убрал coalesce из двух index-driving LIKE-предикатов (title + text_content). Семантически эквивалентно —
NULL LIKE '%q%'= NULL (falsy), NULL-страница не матчится так же, как пустая строка. Теперь title-предикат использует индекс #348idx_pages_title_trgm(убрал избыточный ре-ассерт из своей миграции), text —idx_pages_text_content_trgm(уже был coalesce-free; поправил вводящие в заблуждение комментарии).EXPLAIN-доказательство на реальном PG (дискриминатор
enable_seqscan=off):Bitmap Index Scan on idx_pages_title_trgmBitmap Index Scan on idx_pages_text_content_trgmSeq 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).
Ре-ревью (round 2) — #468
feat(search): агентский lookup-режим (#443, часть 1/3)Вердикт: PASS ✅ — trgm-индекс теперь ЖИВОЙ (подтверждено EXPLAIN'ом), Do закрыт + добавлен регресс-тест.
Fix-round = 1 коммит
f794ac6d, чисто поверх round-1 heade4e788f1; 4 файла (search.service.ts +14, миграция, +новый EXPLAIN-int-spec, +CHANGELOG).Верификация фикса — EXPLAIN на реальном PG (клик)
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(...,'')→ '' для непустого запроса.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)
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 нет.