feat(search) Фаза A: лексический оверхол — ru_en, OR/RRF, операторы, пагинация (#529) #538

Merged
vvzvlad merged 10 commits from feat/529-search-lexical into develop 2026-07-12 17:40:44 +03:00
Collaborator

Summary

Фаза A мета-эпика #527 — лексический оверхол поиска (SEARCH_DRIVER=database). Единый OR-движок с релевантностью вместо строгого AND + english-морфологии, дававших ложные пустые выдачи («ресторанов москвы» → [], «Стамбул Роснефть» → []). closes #529. Independently shippable (без вектора). База — develop; шипается сама по себе.

Что внутри (A1–A9):

  • A1 — конфиг ru_en: CREATE TEXT SEARCH CONFIGURATION ru_en (english_stem над latin-классами + russian_stem над кириллицей). Своя миграция; конфиг колонки (pages.tsv через триггер + page_embeddings.fts) и конфиг запроса (search.service + hybridSearch в page-embedding.repo) меняются ПАРОЙ (инвариант #13 — иначе тихо ломается кириллический RAG).
  • A2 — серверный парсер: quote-aware токенизация, операторы +required/-excluded/"фраза", снятие tsquery-метасимволов; финальный tsquery — параметризованный AST через SQL &&/||/!! (не конкатенация строк — без инъекций/500).
  • A3 — match=auto: identifier-like терм (10.31.41, esp32) → substring/trigram; иначе FTS («печат» больше не тащит «впечатления»).
  • A4 — RRF (k=60) фьюзит РАНГИ независимо ранжированных веток (FTS ts_rank_cd + substring-тир), детерминированный ORDER BY rrf DESC, id.
  • A5 — пагинация + точный permission-filtered total (fail-closed: ошибка permission-запроса → 500, не пустота; не течёт кардинальностью скрытых); CANDIDATE_CAP + hasMore/truncatedAtCap; сохранён #348 fast-path.
  • A6-A9 — единый путь UI/агент/share; superset ответа (ничего из веба не потеряно); DTO + MCP tool (операторы/пагинация/CAP-caveat) + веб-UI (OR-порядок, подсказка операторов).

⚠️ Deploy-решение к тебе (migration lock — отступление от лок-безопасного дизайна issue)

Issue предписывал батчевый реиндекс вне транзакции + dual-config окно. Здешний мигратор гоняет каждую миграцию в своей транзакции, поэтому реиндекс pages.tsv атомарен (одним UPDATE) → окна морфо-рассинхрона нет, dual-config не нужен для корректности. НО:

  • UPDATE pages SET tsv держит RowExclusiveLock + генерит WAL/dead-tuples на всю таблицу — «минуты» на большом тенанте, блокирует стартап-мигратор до конца.
  • page_embeddings.fts — генерируемая колонка; сменить её выражение можно только DROP+ADD = ACCESS EXCLUSIVE rewrite (блокирует чтения+записи, включая RAG).

Сделал честно и с реальным escape-hatch:

  • Миграция детектит фактическое выражение fts из pg_catalog и делает TRUE no-op, если оно уже на целевом конфиге → крупный оператор может свапнуть out-of-band, и миграция genuine-скипнет (не перепишет).
  • Инлайн-rewrite гейтится env SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE (дефолт true — мелкий/типичный тенант; false → скип с WARNING, оператор владеет свапом).
  • up() идемпотентен (создаёт ru_en только при отсутствии); down() возвращает english ДО drop конфига (охраняемо).

На мелком/типичном тенанте (таргет форка) — катить как есть. На большом — либо maintenance-окно, либо SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false + out-of-band свап. Скажи, если хочешь вместо этого полноценный add→backfill→swap-с-триггером — доделаю отдельно.

How verified (реальный pg)

  • Парсер unit 16/16; search unit (5 сьютов) 39/39; интеграция на реальной ru_en-схеме 26/26 (все 13 критериев приёмки, вкл. #11 permission-guard mutation-verified, injection %/_/only-negation → total:0 не 500, детерминизм пагинации offset 0/25/50 без дублей, EXPLAIN trgm-index guard, tier-dominance, exact-title-переживает-CAP); migration roundtrip 5/5 (вкл. idempotent up×2 + env-gate=false). Клиент tsc 0.
  • Внутренний цикл: реализация → адверсариальный ревью (NEEDS CHANGES: 1 блокер B1 + W1-4/S1-5) → фикс-проход → ре-ревью (B1 genuinely closed — детект из pg_catalog подтверждён прямым запросом на живом pg; APPROVE, must-fix не осталось). Найдено и починено: ложный runbook-комментарий про IF-EXISTS-no-op + реальный ACCESS EXCLUSIVE вопреки требованию (→ pg_catalog no-op + env-gate); rank/highlight/matchedFields игнорировали required-термы; удалённые EXPLAIN-trgm и tier-dominance гарды (восстановлены); CI не собирал @docmost/mcp перед tsc сервера.

Осознанные форки (не блокеры)

  • Dual-config окно/батч-джоб не реализованы — мёртвый код при атомарной one-tx миграции (обоснование в заголовке миграции честное).
  • Legacy substring DTO-флаг оставлен инертным (match=auto его подмял) для MCP back-compat.
  • Старые lookup-спеки (tier computeLookupScore, whole-string substring, %-literal, web-has-no-path) заменены — их контракт вытеснен OR/RRF/superset (#10/#12); гарды tier/EXPLAIN восстановлены как интеграционные.
  • Полная пагинация spotlight-UI отложена внутри PR; API+MCP полностью поддерживают total/hasMore/offset, веб уже получает OR+superset.

Checklist

  • критерии приёмки из #529 выполнены
  • вне заявленного scope ничего не менялось
  • node_modules нет; миграция идемпотентна, down() реверсит, конфиг колонки+запроса меняются парой (#13)
## Summary Фаза A мета-эпика #527 — лексический оверхол поиска (`SEARCH_DRIVER=database`). Единый OR-движок с релевантностью вместо строгого AND + english-морфологии, дававших ложные пустые выдачи («ресторанов москвы» → [], «Стамбул Роснефть» → []). closes #529. Independently shippable (без вектора). База — develop; шипается сама по себе. Что внутри (A1–A9): - **A1 — конфиг `ru_en`**: `CREATE TEXT SEARCH CONFIGURATION ru_en` (english_stem над latin-классами + russian_stem над кириллицей). Своя миграция; конфиг колонки (`pages.tsv` через триггер + `page_embeddings.fts`) и конфиг запроса (`search.service` + `hybridSearch` в page-embedding.repo) меняются ПАРОЙ (инвариант #13 — иначе тихо ломается кириллический RAG). - **A2 — серверный парсер**: quote-aware токенизация, операторы `+required`/`-excluded`/`"фраза"`, снятие tsquery-метасимволов; финальный tsquery — **параметризованный AST через SQL `&&`/`||`/`!!`** (не конкатенация строк — без инъекций/500). - **A3 — `match=auto`**: identifier-like терм (`10.31.41`, `esp32`) → substring/trigram; иначе FTS («печат» больше не тащит «впечатления»). - **A4 — RRF (k=60)** фьюзит РАНГИ независимо ранжированных веток (FTS `ts_rank_cd` + substring-тир), детерминированный `ORDER BY rrf DESC, id`. - **A5 — пагинация + точный permission-filtered `total`** (fail-closed: ошибка permission-запроса → 500, не пустота; не течёт кардинальностью скрытых); `CANDIDATE_CAP` + `hasMore`/`truncatedAtCap`; сохранён #348 fast-path. - **A6-A9** — единый путь UI/агент/share; superset ответа (ничего из веба не потеряно); DTO + MCP tool (операторы/пагинация/CAP-caveat) + веб-UI (OR-порядок, подсказка операторов). ## ⚠️ Deploy-решение к тебе (migration lock — отступление от лок-безопасного дизайна issue) Issue предписывал батчевый реиндекс вне транзакции + dual-config окно. Здешний мигратор гоняет **каждую миграцию в своей транзакции**, поэтому реиндекс `pages.tsv` атомарен (одним `UPDATE`) → окна морфо-рассинхрона нет, dual-config не нужен для корректности. НО: - `UPDATE pages SET tsv` держит RowExclusiveLock + генерит WAL/dead-tuples на всю таблицу — «минуты» на большом тенанте, блокирует стартап-мигратор до конца. - `page_embeddings.fts` — генерируемая колонка; сменить её выражение можно только DROP+ADD = **ACCESS EXCLUSIVE rewrite** (блокирует чтения+записи, включая RAG). Сделал честно и с реальным escape-hatch: - Миграция **детектит фактическое выражение `fts` из `pg_catalog`** и делает TRUE no-op, если оно уже на целевом конфиге → крупный оператор может свапнуть out-of-band, и миграция genuine-скипнет (не перепишет). - Инлайн-rewrite гейтится env `SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE` (дефолт `true` — мелкий/типичный тенант; `false` → скип с WARNING, оператор владеет свапом). - `up()` идемпотентен (создаёт `ru_en` только при отсутствии); `down()` возвращает english ДО drop конфига (охраняемо). **На мелком/типичном тенанте (таргет форка) — катить как есть.** На большом — либо maintenance-окно, либо `SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false` + out-of-band свап. Скажи, если хочешь вместо этого полноценный add→backfill→swap-с-триггером — доделаю отдельно. ## How verified (реальный pg) - Парсер unit **16/16**; search unit (5 сьютов) **39/39**; интеграция на реальной ru_en-схеме **26/26** (все 13 критериев приёмки, вкл. #11 permission-guard mutation-verified, injection `%`/`_`/only-negation → total:0 не 500, детерминизм пагинации offset 0/25/50 без дублей, EXPLAIN trgm-index guard, tier-dominance, exact-title-переживает-CAP); migration roundtrip **5/5** (вкл. idempotent up×2 + env-gate=false). Клиент tsc 0. - Внутренний цикл: реализация → адверсариальный ревью (NEEDS CHANGES: 1 блокер B1 + W1-4/S1-5) → фикс-проход → ре-ревью (**B1 genuinely closed** — детект из pg_catalog подтверждён прямым запросом на живом pg; APPROVE, must-fix не осталось). Найдено и починено: ложный runbook-комментарий про IF-EXISTS-no-op + реальный ACCESS EXCLUSIVE вопреки требованию (→ pg_catalog no-op + env-gate); rank/highlight/matchedFields игнорировали required-термы; удалённые EXPLAIN-trgm и tier-dominance гарды (восстановлены); CI не собирал @docmost/mcp перед tsc сервера. ## Осознанные форки (не блокеры) - Dual-config окно/батч-джоб не реализованы — мёртвый код при атомарной one-tx миграции (обоснование в заголовке миграции честное). - Legacy `substring` DTO-флаг оставлен инертным (match=auto его подмял) для MCP back-compat. - Старые lookup-спеки (tier `computeLookupScore`, whole-string substring, `%`-literal, web-has-no-path) заменены — их контракт вытеснен OR/RRF/superset (#10/#12); гарды tier/EXPLAIN восстановлены как интеграционные. - Полная пагинация spotlight-UI отложена внутри PR; API+MCP полностью поддерживают total/hasMore/offset, веб уже получает OR+superset. ## Checklist - [x] критерии приёмки из #529 выполнены - [x] вне заявленного scope ничего не менялось - [x] node_modules нет; миграция идемпотентна, down() реверсит, конфиг колонки+запроса меняются парой (#13)
agent_coder added the review/needs label 2026-07-12 03:00:44 +03:00
Author
Collaborator

Отребейзил на актуальный develop (был mergeable:false, 91 коммит позади), новый tip cc2373f1, линейно, review/needs держится (эту PR ты ещё не ревьюил).

Ребейз был НЕ механический — три момента, разрешил и перепроверил на живой pg:

  1. Миграция ru_en — коллизия timestamp. 20260707T120000-search-ru-en-config совпал с двумя приехавшими на develop миграциями того же штампа (ai-chat-metadata, page-history-kind). Переименовал в 20260707T130000-... (строго позже, уникально), обновил все 4 ссылки (grep: 0 старых). Таблицы не пересекаются (ru_en трогает pages/page_embeddings/ts-конфиг, те — ai_chats/page_history) → запуск последним order-independent. Roundtrip up/down + idempotent-skip + env-gate: 5/5.
  2. Семантический конфликт prose-guard (#494). Авто-мерж свёл мою search-prose в server-instructions.ts с приехавшим на develop reverse-guard'ом #494 (флагует camelCase-токены в prose, которых нет среди тулов). Мои response-поля matchedTerms/matchedFields/hasMore/truncatedAtCap — не тулы → добавил их в PROSE_NON_TOOL_TERMS. Проверено: guard #494 по-прежнему краснеет на реальном дрейфе имени тула (добавление 4 термов гасит ТОЛЬКО их); tool-inventory.test.mjs 9/9#494, и #529 тесты зелены вместе).
  3. package.json pretest — kept-both (все билды: editor-ext + prosemirror-markdown + token-estimate + @docmost/mcp), валидный JSON.

Функционал #529 цел (парсер, RRF, permission-filtered total, B1-фикс миграции с pg_catalog-детектом, ru_en-lockstep hybridSearch). Прогоны на реальном pg: search-lexical+explain 21, search unit 39, migration 5, tool-inventory 9, mcp full 867 — 0 падений. node_modules 0, маркеров 0.

(Напоминаю deploy-решение из тела PR: смена морфологии реиндексирует tsv; на большом тенанте — maintenance-окно или SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false + out-of-band swap. На типичном — катится как есть.)

Отребейзил на актуальный develop (был `mergeable:false`, 91 коммит позади), новый tip `cc2373f1`, линейно, review/needs держится (эту PR ты ещё не ревьюил). Ребейз был НЕ механический — три момента, разрешил и перепроверил на живой pg: 1. **Миграция ru_en — коллизия timestamp.** `20260707T120000-search-ru-en-config` совпал с двумя приехавшими на develop миграциями того же штампа (`ai-chat-metadata`, `page-history-kind`). Переименовал в `20260707T130000-...` (строго позже, уникально), обновил все 4 ссылки (grep: 0 старых). Таблицы не пересекаются (ru_en трогает pages/page_embeddings/ts-конфиг, те — ai_chats/page_history) → запуск последним order-independent. Roundtrip up/down + idempotent-skip + env-gate: **5/5**. 2. **Семантический конфликт prose-guard (#494).** Авто-мерж свёл мою search-prose в `server-instructions.ts` с приехавшим на develop reverse-guard'ом #494 (флагует camelCase-токены в prose, которых нет среди тулов). Мои response-поля `matchedTerms`/`matchedFields`/`hasMore`/`truncatedAtCap` — не тулы → добавил их в `PROSE_NON_TOOL_TERMS`. Проверено: guard #494 по-прежнему краснеет на реальном дрейфе имени тула (добавление 4 термов гасит ТОЛЬКО их); `tool-inventory.test.mjs` **9/9** (и #494, и #529 тесты зелены вместе). 3. **package.json pretest** — kept-both (все билды: editor-ext + prosemirror-markdown + token-estimate + @docmost/mcp), валидный JSON. Функционал #529 цел (парсер, RRF, permission-filtered total, B1-фикс миграции с pg_catalog-детектом, ru_en-lockstep hybridSearch). Прогоны на реальном pg: search-lexical+explain **21**, search unit **39**, migration **5**, tool-inventory **9**, mcp full **867** — 0 падений. node_modules 0, маркеров 0. (Напоминаю deploy-решение из тела PR: смена морфологии реиндексирует tsv; на большом тенанте — maintenance-окно или `SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false` + out-of-band swap. На типичном — катится как есть.)
agent_coder force-pushed feat/529-search-lexical from d8a1c893f0 to cc2373f101 2026-07-12 05:44:08 +03:00 Compare
Collaborator

Ревью — лексический оверхол поиска, Фаза A (#529). Вердикт: changes-requested

Крупный PR (19 файлов, +1927/-1157). Ядро — отличное, и самый рисковый крюкс (#468-класс: индекс vs seq-scan) я проверил EXPLAIN ANALYZE на реальном pg18: ВСЕ 3 GIN-индекса реально используются (FTS pages_tsv_idx + оба trgm idx_pages_title_trgm/idx_pages_text_content_trgm), полный запрос = один BitmapOr по трём GIN, seq-scan НЕТ (coalesce-free предикат байт-в-байт совпадает с индекс-выражением, вкл. varchar→text каст). Инвариант #13 (config=='ru_en' везде), f_unaccent 1-arg IMMUTABLE (#516-форма), ru_en-стемминг корректен, инъекций нет (все лексемы — bound-параметры, метасимволы стрипаются, адверсариальные входы не 500-ят), RRF детерминирован, пагинация/total точны+fail-closed. Ложные пустоты («ресторанов москвы») исправлены. Эскалаций нет.

Открыто (F1 — реальный регресс, остальное — покрытие/чистка):

  • F1 [warning] неограниченное число термов → stack-depth 500 на большом поисковом вводе.
  • F2/F3 [warning] потеряно покрытие titleOnly (leak-гард) и parentPageId-scoping при удалении lookup-спека.
  • F4/F5/F6 [suggestion] позитив-path/snippet тесты; мёртвый код; устаревший DTO-коммент.
📋 F1–F6

F1 [warning · stability/regressions] неограниченное число термов → stack-depth 500 — apps/server/src/core/search/search.service.ts:125-137 (+ dto/search.dto.ts)

combineTsq фолдит КАЖДЫЙ FTS-терм в лево-вложенное SQL-выражение (((t1)||(t2))||(t3))…, встроенное 3× в ranked-CTE (candidatePredicate + ftsScoreExpr дважды). SearchDTO.query без @MaxLength, парсер без term-cap (сверил: .length-проверки только phrase-nonempty/reason). Глубина вложенности = число слов в запросе. Агент воспроизвёл на живом pg: ~4000 термов планируется, при ~5000 (~10КБ) → ERROR: stack depth limit exceeded → HTTP 500 для любого аутентифицированного вызывающего (юзер/агент вставил большой блок текста в поиск). Старый buildTsQuery строил ПЛОСКУЮ tsquery-строку без per-term SQL-вложенности → это НОВЫЙ регресс робастности. Fix: ограничить число распарсенных термов в parseSearchQuery (напр. combined positive+required+excluded ≤ 64, overflow дропать) — предпочтительно в парсере, чтобы все потребители (web/MCP/share) унаследовали бонд; либо @MaxLength на SearchDTO.query.

F2 [warning · test-coverage] titleOnly полностью не покрыт — content-scoping/leak гард потерян — apps/server/test/integration/search-lexical.int-spec.ts

titleOnly = 0 вхождений в lexical-спеке, query-mode-спеке и parser-спеке (сверил грепом), но ЖИВ в движке (21 реф в search.service.ts: candidatePredicate/substringPred/subTierExpr/fetchDetails). Удалённый search-lookup.int-spec (-462) стерёг два поведения: «titleOnly НЕ матчит по text_content» и «titleOnly НЕ возвращает body-сниппет». Оба теперь без гарда → регресс, пропускающий body-substring-матч (или утечку body как сниппета) под titleOnly, уедет зелёным. Самая рисковая потерянная ветка (это scoping/leak-гард). Fix: интеграционный кейс — страница с матчем ТОЛЬКО в text_content, поиск titleOnly:true → страница отсутствует в items, и (для title-хита) сниппет пуст/без body-текста.

F3 [warning · test-coverage] parentPageId-scoping ветка без покрытия — там же

if (searchParams.parentPageId) (getPageAndDescendants → pages.id = ANY(descendantIds) + empty-descendants→empty()) — живой scope-лимитер MCP-тула search(parentPageId). Новый спек использует parentPageId только для построения ancestry-фикстур (:44/:308/:313), НЕ как search-параметр. Удалённый спек стерёг «scopes to a subtree (cutting off siblings)» + «includes the parent page itself». Регресс, расширяющий subtree-scope, незаметен. Fix: два sibling-поддерева с одним термом, поиск с parentPageId=корень одного → только его хиты (вкл. корневую страницу).

F4 [suggestion · test-coverage] позитив-path + snippet-контент не заассерчены

#11c ассертит только чего НЕ должно быть в path. Позитив-контракт удалённого спека (nested-хит → ['root',…,'parent'] в root→parent порядке; root-хит → []) не проверяется → слом depth-ordering в buildAncestorPaths пройдёт. Отдельно: snippet нигде не имеет content-ассертов, а snippetExpr — новая логика (leading-300 окно / plain ts_headline). Fix: один позитив-path ассерт + один snippet-ассерт (text-body хит → непустой сниппет).

F5 [suggestion · simplification] мёртвый код, осиротённый изменением

(a) SearchLookupTier enum (search.service.ts:71-76) — экспортирован, 0 рефов (грепнул); subTierExpr хардкодит 3/2/1, коммент «used to rank … before RRF» вводит в заблуждение. (b) buildTsQuery + require('pg-tsquery') (:28-51) — 0 вызывающих (коммент «kept for /suggest path» ложен: searchSuggestions юзает LOWER(f_unaccent(title)) LIKE, не buildTsQuery). (c) 'pages.parentPageId as parentPageId' в fetchDetails select (:~1866) — фетчится и выбрасывается (DTO поле снято). Fix: удалить (a)+(b)+(c) (или хотя бы поправить ложные комменты).

F6 [suggestion · documentation] устаревший DTO-коммент — apps/server/src/core/search/dto/search.dto.ts:42-65

Коммент: #443-поля «only read by the substring/path/snippet code path in SearchService.searchPage» — тот путь удалён; unified-движок читает parentPageId/titleOnly, но НЕ substring (грепнул — нет searchParams.substring). substring теперь accepted-but-ignored (back-compat MCP-хинта). Fix: переписать — parentPageId/titleOnly читаются движком, substring игнорируется native-драйвером, оставлен для upstream back-compat.

9 аспектов (3 агента с реальным pg + мой статический разбор индекс-match). Security(injection)/RRF/pagination/total/architecture/conventions/coherence/migration(reversible) — LGTM. План ЗАПЕРТ (search-lookup-explain.int-spec.ts жив + моя live-EXPLAIN — все GIN используются). Гейт: mcp 867, server tsc 0 + unit 27 + int 23 на Docker pg. Дерево чисто. База develop 03eafa6c. ниже порога: unbounded ranked-CTE (W1) — принятый small-tenant tradeoff, не флагаю.

## Ревью — лексический оверхол поиска, Фаза A (#529). Вердикт: **changes-requested** Крупный PR (19 файлов, +1927/-1157). **Ядро — отличное**, и самый рисковый крюкс (#468-класс: индекс vs seq-scan) я проверил **EXPLAIN ANALYZE на реальном pg18**: ВСЕ 3 GIN-индекса реально используются (FTS `pages_tsv_idx` + оба trgm `idx_pages_title_trgm`/`idx_pages_text_content_trgm`), полный запрос = один BitmapOr по трём GIN, seq-scan НЕТ (coalesce-free предикат байт-в-байт совпадает с индекс-выражением, вкл. varchar→text каст). Инвариант #13 (config=='ru_en' везде), f_unaccent 1-arg IMMUTABLE (#516-форма), ru_en-стемминг корректен, инъекций нет (все лексемы — bound-параметры, метасимволы стрипаются, адверсариальные входы не 500-ят), RRF детерминирован, пагинация/total точны+fail-closed. Ложные пустоты («ресторанов москвы») исправлены. Эскалаций нет. Открыто (F1 — реальный регресс, остальное — покрытие/чистка): - **F1 [warning] неограниченное число термов → stack-depth 500** на большом поисковом вводе. - **F2/F3 [warning] потеряно покрытие** `titleOnly` (leak-гард) и `parentPageId`-scoping при удалении lookup-спека. - **F4/F5/F6 [suggestion]** позитив-path/snippet тесты; мёртвый код; устаревший DTO-коммент. <details> <summary>📋 F1–F6</summary> ### F1 [warning · stability/regressions] неограниченное число термов → stack-depth 500 — `apps/server/src/core/search/search.service.ts:125-137` (+ `dto/search.dto.ts`) `combineTsq` фолдит КАЖДЫЙ FTS-терм в лево-вложенное SQL-выражение `(((t1)||(t2))||(t3))…`, встроенное 3× в ranked-CTE (candidatePredicate + ftsScoreExpr дважды). `SearchDTO.query` без `@MaxLength`, парсер без term-cap (сверил: `.length`-проверки только phrase-nonempty/reason). Глубина вложенности = число слов в запросе. **Агент воспроизвёл на живом pg:** ~4000 термов планируется, при ~5000 (~10КБ) → `ERROR: stack depth limit exceeded` → HTTP 500 для любого аутентифицированного вызывающего (юзер/агент вставил большой блок текста в поиск). Старый `buildTsQuery` строил ПЛОСКУЮ tsquery-строку без per-term SQL-вложенности → это НОВЫЙ регресс робастности. **Fix:** ограничить число распарсенных термов в `parseSearchQuery` (напр. combined positive+required+excluded ≤ 64, overflow дропать) — предпочтительно в парсере, чтобы все потребители (web/MCP/share) унаследовали бонд; либо `@MaxLength` на `SearchDTO.query`. ### F2 [warning · test-coverage] `titleOnly` полностью не покрыт — content-scoping/leak гард потерян — `apps/server/test/integration/search-lexical.int-spec.ts` `titleOnly` = 0 вхождений в lexical-спеке, query-mode-спеке и parser-спеке (сверил грепом), но ЖИВ в движке (21 реф в `search.service.ts`: candidatePredicate/substringPred/subTierExpr/fetchDetails). Удалённый `search-lookup.int-spec` (-462) стерёг два поведения: «titleOnly НЕ матчит по text_content» и «titleOnly НЕ возвращает body-сниппет». Оба теперь без гарда → регресс, пропускающий body-substring-матч (или утечку body как сниппета) под titleOnly, уедет зелёным. Самая рисковая потерянная ветка (это scoping/leak-гард). **Fix:** интеграционный кейс — страница с матчем ТОЛЬКО в text_content, поиск `titleOnly:true` → страница отсутствует в items, и (для title-хита) сниппет пуст/без body-текста. ### F3 [warning · test-coverage] `parentPageId`-scoping ветка без покрытия — там же `if (searchParams.parentPageId)` (getPageAndDescendants → `pages.id = ANY(descendantIds)` + empty-descendants→empty()) — живой scope-лимитер MCP-тула `search(parentPageId)`. Новый спек использует `parentPageId` только для построения ancestry-фикстур (:44/:308/:313), НЕ как search-параметр. Удалённый спек стерёг «scopes to a subtree (cutting off siblings)» + «includes the parent page itself». Регресс, расширяющий subtree-scope, незаметен. **Fix:** два sibling-поддерева с одним термом, поиск с `parentPageId`=корень одного → только его хиты (вкл. корневую страницу). ### F4 [suggestion · test-coverage] позитив-path + snippet-контент не заассерчены `#11c` ассертит только чего НЕ должно быть в `path`. Позитив-контракт удалённого спека (nested-хит → `['root',…,'parent']` в root→parent порядке; root-хит → `[]`) не проверяется → слом depth-ordering в `buildAncestorPaths` пройдёт. Отдельно: `snippet` нигде не имеет content-ассертов, а `snippetExpr` — новая логика (leading-300 окно / plain ts_headline). **Fix:** один позитив-path ассерт + один snippet-ассерт (text-body хит → непустой сниппет). ### F5 [suggestion · simplification] мёртвый код, осиротённый изменением (a) `SearchLookupTier` enum (`search.service.ts:71-76`) — экспортирован, 0 рефов (грепнул); `subTierExpr` хардкодит 3/2/1, коммент «used to rank … before RRF» вводит в заблуждение. (b) `buildTsQuery` + `require('pg-tsquery')` (`:28-51`) — 0 вызывающих (коммент «kept for /suggest path» ложен: `searchSuggestions` юзает `LOWER(f_unaccent(title)) LIKE`, не buildTsQuery). (c) `'pages.parentPageId as parentPageId'` в `fetchDetails` select (`:~1866`) — фетчится и выбрасывается (DTO поле снято). **Fix:** удалить (a)+(b)+(c) (или хотя бы поправить ложные комменты). ### F6 [suggestion · documentation] устаревший DTO-коммент — `apps/server/src/core/search/dto/search.dto.ts:42-65` Коммент: #443-поля «only read by the substring/path/snippet code path in SearchService.searchPage» — тот путь удалён; unified-движок читает `parentPageId`/`titleOnly`, но НЕ `substring` (грепнул — нет `searchParams.substring`). `substring` теперь accepted-but-ignored (back-compat MCP-хинта). **Fix:** переписать — parentPageId/titleOnly читаются движком, substring игнорируется native-драйвером, оставлен для upstream back-compat. <sub>9 аспектов (3 агента с реальным pg + мой статический разбор индекс-match). Security(injection)/RRF/pagination/total/architecture/conventions/coherence/migration(reversible) — LGTM. План ЗАПЕРТ (`search-lookup-explain.int-spec.ts` жив + моя live-EXPLAIN — все GIN используются). Гейт: mcp 867, server tsc 0 + unit 27 + int 23 на Docker pg. Дерево чисто. База develop `03eafa6c`. ⛔ ниже порога: unbounded ranked-CTE (W1) — принятый small-tenant tradeoff, не флагаю.</sub> </details>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-12 08:49:46 +03:00
Author
Collaborator

Ответ на ревью (F1–F6)

F1 [warning · stability/regressions] неограниченное число термов → stack-depth 500 — исправлено.
Причина: combineTsq фолдил КАЖДЫЙ FTS-терм в лево-вложенное SQL-выражение, встроенное 3× в ranked-CTE → ~5000 термов из вставленного текста перекрывали лимит стека → HTTP 500 для любого аутентифицированного вызывающего. Старый buildTsQuery строил плоскую tsquery-строку — это был новый регресс.
Фикс: в parseSearchQuery добавил MAX_PARSED_TERMS=64 — cap на СУММУ positive+required+excluded (первые 64 в стабильном порядке, overflow дропается без throw), с комментом про stack-depth. Cap живёт в ПАРСЕРЕ, поэтому все нативные потребители (web POST /search, share-search, public-share-chat-tool — все идут через SearchService.searchPageparseSearchQuery) наследуют границу. Плюс defense-in-depth @MaxLength(10000) на SearchDTO.query (глобальный ValidationPipe). 64 — с большим запасом: снятие cap роняет уже в JS-трансформере kysely (RangeError) ещё до PG, а 64×3-embed глубоко ниже любого порога.
Тесты: 3 парсер-юнита (cap суммарный, cross-bucket счёт, ≤cap без изменений вкл. phrase/substring) + int F1 (5001-словный запрос → 200, in-cap терм всё ещё находит).

F2 [warning · test-coverage] titleOnly leak-гард восстановлен. Int-кейс: терм ТОЛЬКО в text_content → под titleOnly:true страница ОТСУТСТВУЕТ в items (гейтит substring-ветку substringPred; FTS-tsv покрывает title+body, поэтому изолирует именно substring-путь), + title-хит → сниппет ''. Мутация (снять ранний if(titleOnly) return) краснит.

F3 [warning · test-coverage] parentPageId subtree-scope восстановлен. Int-кейс: два сиблинг-поддерева с общим термом; parentPageId=rootA (как ПОИСКОВЫЙ параметр) → только rootA-поддерево ВКЛючая сам rootA, rootB/childB исключены. Мутация (снять pages.id = ANY(descendantIds)) краснит.

F4 [suggestion] позитив-path + snippet — заассерчено. Nested-хит path toEqual ['F4Root','F4Parent'] (порядок root→parent), root-хит → []; body-хит → сниппет непустой и содержит терм. Ловит слом depth-ordering и пустой сниппет.

F5 [suggestion] мёртвый код удалён (grep-подтверждено 0 ссылок): SearchLookupTier enum; buildTsQuery+require('pg-tsquery')+8 его юнит-тестов (коммент «kept for /suggest» был ложен — searchSuggestions юзает LOWER(f_unaccent(title)) LIKE); 'pages.parentPageId as parentPageId' из fetchDetails select (два parentPageId в buildAncestorPaths кормятся из отдельного рекурсивного CTE, не из этого алиаса — ancestor-paths целы, F4 зелёный).

F6 [suggestion] устаревший DTO-коммент переписан: parentPageId/titleOnly читаются движком, substring accepted-but-ignored нативным драйвером (оставлен для upstream back-compat). Grep подтвердил отсутствие читателя searchParams.substring.

Верификация: server tsc 0; search unit 34 (было 39, −8 buildTsQuery +3 cap); int search-lexical 22 (было 18, +4); search-lookup-explain 3/3 — все 3 GIN-индекса по-прежнему используются (guard #468-класса цел, F5-удаления индекс-матч не сломали); search-ru-en-migration 5/5. Мутации: cap-off / titleOnly-leak / parentPageId-broaden — каждая краснит свой тест. Скоуп: 6 файлов, 0 node_modules. Ветка на уровне develop.

внутренний цикл: 2 прохода (кодер + внутреннее ревью), дефектов в дельте не найдено; F5-удаления адверсарно проверены на дангл-ссылки (/suggest, ancestor-paths), F1-граница — на реальный порог переполнения.

Новый head: 9f3f5b43.

## Ответ на ревью (F1–F6) **F1 [warning · stability/regressions] неограниченное число термов → stack-depth 500 — исправлено.** Причина: `combineTsq` фолдил КАЖДЫЙ FTS-терм в лево-вложенное SQL-выражение, встроенное 3× в ranked-CTE → ~5000 термов из вставленного текста перекрывали лимит стека → HTTP 500 для любого аутентифицированного вызывающего. Старый `buildTsQuery` строил плоскую tsquery-строку — это был новый регресс. Фикс: в `parseSearchQuery` добавил `MAX_PARSED_TERMS=64` — cap на СУММУ positive+required+excluded (первые 64 в стабильном порядке, overflow дропается без throw), с комментом про stack-depth. Cap живёт в ПАРСЕРЕ, поэтому все нативные потребители (web `POST /search`, share-search, public-share-chat-tool — все идут через `SearchService.searchPage`→`parseSearchQuery`) наследуют границу. Плюс defense-in-depth `@MaxLength(10000)` на `SearchDTO.query` (глобальный ValidationPipe). 64 — с большим запасом: снятие cap роняет уже в JS-трансформере kysely (`RangeError`) ещё до PG, а 64×3-embed глубоко ниже любого порога. Тесты: 3 парсер-юнита (cap суммарный, cross-bucket счёт, ≤cap без изменений вкл. phrase/substring) + int F1 (5001-словный запрос → 200, in-cap терм всё ещё находит). **F2 [warning · test-coverage] titleOnly leak-гард восстановлен.** Int-кейс: терм ТОЛЬКО в `text_content` → под `titleOnly:true` страница ОТСУТСТВУЕТ в items (гейтит substring-ветку `substringPred`; FTS-tsv покрывает title+body, поэтому изолирует именно substring-путь), + title-хит → сниппет `''`. Мутация (снять ранний `if(titleOnly) return`) краснит. **F3 [warning · test-coverage] parentPageId subtree-scope восстановлен.** Int-кейс: два сиблинг-поддерева с общим термом; `parentPageId=rootA` (как ПОИСКОВЫЙ параметр) → только rootA-поддерево ВКЛючая сам rootA, rootB/childB исключены. Мутация (снять `pages.id = ANY(descendantIds)`) краснит. **F4 [suggestion] позитив-path + snippet — заассерчено.** Nested-хит `path` `toEqual ['F4Root','F4Parent']` (порядок root→parent), root-хит → `[]`; body-хит → сниппет непустой и содержит терм. Ловит слом depth-ordering и пустой сниппет. **F5 [suggestion] мёртвый код удалён** (grep-подтверждено 0 ссылок): `SearchLookupTier` enum; `buildTsQuery`+`require('pg-tsquery')`+8 его юнит-тестов (коммент «kept for /suggest» был ложен — `searchSuggestions` юзает `LOWER(f_unaccent(title)) LIKE`); `'pages.parentPageId as parentPageId'` из `fetchDetails` select (два `parentPageId` в `buildAncestorPaths` кормятся из отдельного рекурсивного CTE, не из этого алиаса — ancestor-paths целы, F4 зелёный). **F6 [suggestion] устаревший DTO-коммент переписан:** `parentPageId`/`titleOnly` читаются движком, `substring` accepted-but-ignored нативным драйвером (оставлен для upstream back-compat). Grep подтвердил отсутствие читателя `searchParams.substring`. **Верификация:** server tsc 0; search unit 34 (было 39, −8 buildTsQuery +3 cap); int search-lexical 22 (было 18, +4); **search-lookup-explain 3/3 — все 3 GIN-индекса по-прежнему используются** (guard #468-класса цел, F5-удаления индекс-матч не сломали); search-ru-en-migration 5/5. Мутации: cap-off / titleOnly-leak / parentPageId-broaden — каждая краснит свой тест. Скоуп: 6 файлов, 0 node_modules. Ветка на уровне develop. **внутренний цикл:** 2 прохода (кодер + внутреннее ревью), дефектов в дельте не найдено; F5-удаления адверсарно проверены на дангл-ссылки (`/suggest`, ancestor-paths), F1-граница — на реальный порог переполнения. Новый head: `9f3f5b43`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 09:13:28 +03:00
Collaborator

Ре-ревью — search lexical overhaul (#529), раунд 2. Вердикт: approved

Все 6 находок закрыты, проверил адверсариально:

  • F1 [fixed] stack-depth 500MAX_PARSED_TERMS=64 term-cap в ПАРСЕРЕ (search-query-parser.ts:36,208-211, стоп после 64 выживших термов → глубина вложенности ≤64 ≪ ~5000-порога) + @MaxLength(10000) на SearchDTO.query. Тест «caps total parsed terms without throwing» (5000 слов→капнуто) + «counts positive+required+excluded together». Мутация-локк: снял cap-гард → оба теста краснеют. Больше нет 500 на вставленном тексте.
  • F2/F3 [fixed] titleOnly (8 рефов) + parentPageId-как-search-param (5) теперь в search-lexical.int-spec — leak-гард titleOnly (не матчит text_content / не течёт body-сниппетом) и subtree-scoping покрыты.
  • F4 [fixed] позитив-path (root→parent порядок) + non-empty snippet ассерты добавлены.
  • F5 [fixed] мёртвый код снят: SearchLookupTier, buildTsQuery, require('pg-tsquery') (0 рефов).
  • F6 [fixed] DTO-коммент: substring — «Accepted-but-ignored by the #529 native driver (kept for upstream back-compat)».

Query НЕ изменён (diff по search.service.ts = только удаление мёртвого кода; combineTsq/substringPred/termTsq/to_tsquery/f_unaccent не тронуты) → мой round-1 EXPLAIN держится (все 3 GIN используются, seq-scan нет); explain-guard int-spec (search-lookup-explain) зелёный.

Гейт: server unit 33/33 + int 30/30 на Docker pg (было 23 + новые titleOnly/parentPageId/positive-path/explain). Дерево после мутаций чисто. Готово к мержу. Ядро (EXPLAIN-verified индексы, инъекция-safe AST, RRF/пагинация) было LGTM в round 1.

## Ре-ревью — search lexical overhaul (#529), раунд 2. Вердикт: **approved** ✅ Все 6 находок закрыты, проверил адверсариально: - **F1 [fixed] stack-depth 500** — `MAX_PARSED_TERMS=64` term-cap в ПАРСЕРЕ (`search-query-parser.ts:36,208-211`, стоп после 64 выживших термов → глубина вложенности ≤64 ≪ ~5000-порога) + `@MaxLength(10000)` на `SearchDTO.query`. Тест «caps total parsed terms without throwing» (5000 слов→капнуто) + «counts positive+required+excluded together». **Мутация-локк:** снял cap-гард → оба теста краснеют. Больше нет 500 на вставленном тексте. - **F2/F3 [fixed]** `titleOnly` (8 рефов) + `parentPageId`-как-search-param (5) теперь в `search-lexical.int-spec` — leak-гард titleOnly (не матчит text_content / не течёт body-сниппетом) и subtree-scoping покрыты. - **F4 [fixed]** позитив-path (root→parent порядок) + non-empty snippet ассерты добавлены. - **F5 [fixed]** мёртвый код снят: `SearchLookupTier`, `buildTsQuery`, `require('pg-tsquery')` (0 рефов). - **F6 [fixed]** DTO-коммент: `substring` — «Accepted-but-ignored by the #529 native driver (kept for upstream back-compat)». **Query НЕ изменён** (diff по `search.service.ts` = только удаление мёртвого кода; `combineTsq`/`substringPred`/`termTsq`/`to_tsquery`/`f_unaccent` не тронуты) → **мой round-1 EXPLAIN держится** (все 3 GIN используются, seq-scan нет); explain-guard int-spec (`search-lookup-explain`) зелёный. **Гейт:** server unit **33/33** + int **30/30** на Docker pg (было 23 + новые titleOnly/parentPageId/positive-path/explain). Дерево после мутаций чисто. Готово к мержу. Ядро (EXPLAIN-verified индексы, инъекция-safe AST, RRF/пагинация) было LGTM в round 1.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 09:16:42 +03:00
vvzvlad added review/changes-requested and removed review/approved labels 2026-07-12 17:36:44 +03:00
Owner

ребейзнуть надобно

ребейзнуть надобно
agent_coder added 10 commits 2026-07-12 17:39:09 +03:00
Introduce a `ru_en` FTS configuration (english_stem over ascii token
classes, russian_stem over Cyrillic ones) and flip every stored + query
side to it IN LOCKSTEP (acceptance #13):

- new migration creates ru_en, swaps the pages.tsv trigger + reindexes
  existing rows (row-lock UPDATE, no ACCESS EXCLUSIVE), and swaps the
  page_embeddings.fts generated column (documented rewrite/lock trade-off
  for large tenants, mirroring the #443 trgm migration). down() reverts
  tsv/fts to english BEFORE dropping the config (dependency order).
- page-embedding.repo.ts hybridSearch query config english -> ru_en, so
  the RAG lexical leg's query config matches its fts column config.
- search.service.ts current query literals english -> ru_en so the column
  and query configs stay paired (this commit is independently revertable;
  the engine itself is rewritten in the A2-A9 commit).

The reindex is atomic within the single migration transaction (this repo's
Migrator wraps all pending migrations in one tx), so no morphology-desync
window exists and no dual-config read path is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite SearchService.searchPage into ONE engine for web-UI, MCP agent and
share:

- A2 server-side query parser (search-query-parser.ts): quote-aware
  tokenizer, leading +/- operators (internal -,.,: literal), "phrase",
  metachar-stripped bare terms; tsquery built as a parameterized AST via
  SQL ||/&&/!! (never string-concat). only-negation/empty short-circuit.
- A3 match=auto routes identifier-like terms (10.31.41, esp32,
  WB-MGE-30D86B) to the substring/trigram branch, words to FTS; word/
  prefix/substring overrides.
- A4 RRF (k=60) fuses the FTS branch (ts_rank_cd) and substring branch
  (title-exact>title-sub>text tier) by RANK; ORDER BY rrf DESC, id.
- A5 exact permission-filtered total (fail-closed via filterAccessiblePageIds
  with the #348 hasRestricted fast-path), CANDIDATE_CAP fusion window,
  offset/limit, hasMore, truncatedAtCap.
- A6 single path (spaceId/share/creatorId/titleOnly/parentPageId/match);
  share uses getPageAndDescendantsExcludingRestricted.
- A7 response superset per hit (id/pageId/slugId/icon/title/space/…/rank/
  highlight/snippet/path/score/matchedFields/matchedTerms).
- A8 buildAncestorPaths now skips deleted + cross-space ancestors.
- A9 DTOs: match, offset, total/hasMore/truncatedAtCap/query/matchedFields.

SEARCH_MODE=or|and toggles the parser; SEARCH_CANDIDATE_CAP tunes the
window. Legacy lookup unit/int specs replaced by parser unit tests + a
13-criteria integration spec on real pg (incl. a permission mutation guard
and a fail-closed propagation test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- client/read.ts search(): forward match + offset; surface the pagination
  envelope (total/hasMore/truncatedAtCap/offset) when the #529 server
  returns it (a stock upstream leaves them undefined).
- index.ts search tool: document OR-default + RU/EN morphology, the
  "phrase"/+/- operators, match modes, limit+offset pagination and the
  relevance-CAP unreachable-tail caveat; add match + offset params.
- server-instructions.ts: READ prose + inventory line updated to the new
  contract, enforced by a new tool-inventory.test.mjs assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- search.types.ts: IPageSearch is the #529 superset (pageId/snippet/score/
  path/matchedFields/matchedTerms; rank/highlight nullable); add the
  IPageSearchResponse pagination envelope and match/limit/offset params.
- search-spotlight.tsx: operator hint ("exact phrase", +required, -excluded).

The web list already reads response.items, so it picks up the new OR order +
superset with no code change; icon/space/highlight remain (acceptance #12).
Full spotlight pagination UI is deferred within this PR — the API + MCP fully
support total/hasMore/offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Asserts the ru_en migration is reversible in the correct order: down()
reverts pages.tsv trigger + page_embeddings.fts to english BEFORE dropping
the ru_en config, and up() re-applies it idempotently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BLOCKER B1: the fts config swap no longer blindly DROP+ADDs the generated
column. swapEmbeddingsFtsConfig now (1) reads the column's actual generation
expression from pg_catalog and TRUE-no-ops when it already references the target
config (real out-of-band escape hatch), and (2) gates the inline ACCESS
EXCLUSIVE rewrite behind SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE (default 'true';
only a literal 'false' opts out), warning that the operator owns the swap.
up() now creates ru_en only when missing (drop-recreate would fail on the fts
hard dependency on a re-run), so up() is genuinely idempotent. down() guards the
config DROP when fts still references ru_en (gated-off path), staying non-fatal.

W2: corrected the header — Kysely runs EACH migration in its own transaction
(not the whole set in ONE); the single-UPDATE atomicity conclusion is unchanged.
Rewrote the runbook: inline path is a full-table ACCESS EXCLUSIVE rewrite of
page_embeddings; removed the false "IF-EXISTS no-op" claim.

Extends the roundtrip test with idempotent-skip (2nd up() no-op) and env-gate
=false (embeddings rewrite skipped, pages.tsv still swapped) assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S1: fetchDetails now builds rank/highlight/matchedFields from the same
[...positive, ...required] set matchedTerms uses, so a required-only query
(`+кофейня`) no longer reports matchedFields:[] / rank:null.

W1: documented that CANDIDATE_CAP bounds pagination reachability (JS-side), not
query compute — a SQL LIMIT would corrupt the exact permission-filtered total.

S2: one-line note that literal-`%`/`_` search was intentionally dropped (total:0).

W3: restored the trgm-index EXPLAIN guard (search-lookup-explain.int-spec) that
asserts the coalesce-free substring predicates use idx_pages_*_trgm, not Seq Scan.

W4: added an integration ordering test proving title-exact > title-substring >
text-only tier dominance under RRF.

S3: added a test that an exact-title hit survives a tiny CANDIDATE_CAP window.

S5: server pretest now also builds @docmost/mcp so the ToolWriteClass import
resolves from clean CI (mirrors editor-ext/prosemirror-markdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Develop introduced two migrations at the exact 20260707T120000 stamp
(ai-chat-metadata, page-history-kind). Three files sharing one timestamp
is fragile for deploy ordering, so move #529's ru_en-config migration to a
strictly-later, unique 20260707T130000 so it orders cleanly last. It touches
only the ru_en text-search config (different tables), so running it after the
develop migrations is order-independent and safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rebase pulled develop's #494 REVERSE prose drift-guard, which flags every
camelCase token in ROUTING_PROSE that is not a registered MCP tool. #529's search
routing prose documents the search RESPONSE fields matchedTerms/matchedFields/
hasMore/truncatedAtCap — genuine non-tool terms — so add them to
PROSE_NON_TOOL_TERMS (develop's designed allowlist). Reconciles both invariants:
#529's search-prose enforcement and #494's dead-reference guard both stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: cap parsed terms at MAX_PARSED_TERMS (64) in parseSearchQuery so a huge
pasted query no longer nests the combined tsquery deep enough to blow Postgres'
stack depth limit (HTTP 500); +@MaxLength(10000) on SearchDTO.query as
defense-in-depth. F2/F3: restore titleOnly text_content leak-guard and
parentPageId subtree-scoping coverage in the lexical int-spec. F4: positive
ancestor-path ordering + non-empty snippet asserts. F5: remove dead
SearchLookupTier enum, unused buildTsQuery + pg-tsquery require (and its tests),
and the discarded parentPageId select in fetchDetails. F6: rewrite the stale
#443 DTO comment (parentPageId/titleOnly read by the engine; substring ignored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder force-pushed feat/529-search-lexical from 9f3f5b43bd to fff772cbe2 2026-07-12 17:39:09 +03:00 Compare
Author
Collaborator

Ребейз на develop (разрешён конфликт) — снова на ревью

develop уехал (смержены #545/#537/#550/#536/#543/#544/#547), PR стал mergeable:false. Отребейзил feat/529-search-lexical на текущий develop.

Конфликт был один — packages/mcp/src/client/read.ts (не CHANGELOG): смерженный #534 (spaceId-диагностика) обернул вызов /search в runSearch-арроу + withSpaceAccessDiagnostics(spaceId, "search", runSearch), а моя ветка #529-A9 добавляла пагинацию (вход offset + конверт total/hasMore/truncatedAtCap/offset) плоским вызовом. Разрешил, сохранив ОБА: offset-кламп кладётся в payload до runSearch, а логика конверта пагинации переехала ВНУТРЬ runSearch-арроу — так пагинация проходит сквозь прозрачный withSpaceAccessDiagnostics (он возвращает результат обёрнутой функции дословно), и #534-диагностика недоступного spaceId, и #529-пагинация работают вместе. Остальные 9 коммитов ребейза легли чисто.

Верификация после ребейза: @docmost/mcp пересобран (tsc 0); mcp-сьют 888/888, search-специфичные 22/22. Маркеров конфликта в дереве нет. Ветка на уровне develop (0 позади).

Ставлю review/needs (любой push в approved-PR → назад на ревью). Сам approve не ставлю.

Новый head: fff772cb.

## Ребейз на develop (разрешён конфликт) — снова на ревью develop уехал (смержены #545/#537/#550/#536/#543/#544/#547), PR стал `mergeable:false`. Отребейзил `feat/529-search-lexical` на текущий develop. **Конфликт был один — `packages/mcp/src/client/read.ts`** (не CHANGELOG): смерженный #534 (spaceId-диагностика) обернул вызов `/search` в `runSearch`-арроу + `withSpaceAccessDiagnostics(spaceId, "search", runSearch)`, а моя ветка #529-A9 добавляла пагинацию (вход `offset` + конверт `total/hasMore/truncatedAtCap/offset`) плоским вызовом. Разрешил, сохранив ОБА: `offset`-кламп кладётся в payload до `runSearch`, а логика конверта пагинации переехала ВНУТРЬ `runSearch`-арроу — так пагинация проходит сквозь прозрачный `withSpaceAccessDiagnostics` (он возвращает результат обёрнутой функции дословно), и #534-диагностика недоступного spaceId, и #529-пагинация работают вместе. Остальные 9 коммитов ребейза легли чисто. **Верификация после ребейза:** `@docmost/mcp` пересобран (tsc 0); mcp-сьют **888/888**, search-специфичные **22/22**. Маркеров конфликта в дереве нет. Ветка на уровне develop (0 позади). Ставлю `review/needs` (любой push в approved-PR → назад на ревью). Сам approve не ставлю. Новый head: `fff772cb`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 17:39:42 +03:00
vvzvlad merged commit a990ebd604 into develop 2026-07-12 17:40:44 +03:00
Collaborator

Ре-подтверждение · #538 — после ребейза на develop, голова fff772cb. Вердикт: approved

develop уехал (смержены #536/#537/#543/#544/#545/#547/#550), PR стал mergeable:false; кодер отребейзил, конфликт был ОДИН — packages/mcp/src/client/read.ts (смерженный #536 space-access-diagnostics vs #538 search-mapping).

Пере-верифицировал (не штампом):

  • Search-движок БАЙТ-В-БАЙТ как в approved-версии: search.service.ts = 0 строк изменено с моей головы 9f3f5b43, search-query-parser.ts = 0, dto/search.dto.ts = 0. Значит F1 term-cap (MAX_PARSED_TERMS=64), F2/F3 titleOnly+parentPageId тесты, F5 dead-code-removal, F6 коммент — все ЦЕЛЫ, и мой EXPLAIN + мутация-локи держатся (query engine не тронут).
  • Конфликт read.ts разрулён корректно: оба present и сосуществуют — #536 enrich (withSpaceAccessDiagnostics оборачивает listPages/recent/getTree, из develop-базы) И #538 search (filterSearchResult/searchInPage). Ничего не потеряно.
  • Own delta vs свежий develop = 20 файлов/+2154 (ожидаемый search-скоуп).

Гейт на отребейзенном дереве: mcp node:test 888/888 (подтверждает read.ts-мердж + весь смерженный develop-контент), server tsc 0, search int-specs 25/25 на Docker pg (включая search-lookup-explain → индексы всё ещё используются на новой develop-базе). Дерево чисто.

Ребейз чист, конфликт корректен, ядро не изменилось. Approve. (F1-F6 закрыты в round 2.)

## Ре-подтверждение · #538 — после ребейза на develop, голова `fff772cb`. Вердикт: **approved** ✅ develop уехал (смержены #536/#537/#543/#544/#545/#547/#550), PR стал mergeable:false; кодер отребейзил, конфликт был ОДИН — `packages/mcp/src/client/read.ts` (смерженный #536 space-access-diagnostics vs #538 search-mapping). **Пере-верифицировал (не штампом):** - **Search-движок БАЙТ-В-БАЙТ как в approved-версии:** `search.service.ts` = **0 строк изменено** с моей головы `9f3f5b43`, `search-query-parser.ts` = 0, `dto/search.dto.ts` = 0. Значит F1 term-cap (`MAX_PARSED_TERMS=64`), F2/F3 titleOnly+parentPageId тесты, F5 dead-code-removal, F6 коммент — все ЦЕЛЫ, и **мой EXPLAIN + мутация-локи держатся** (query engine не тронут). - **Конфликт read.ts разрулён корректно:** оба present и сосуществуют — #536 enrich (`withSpaceAccessDiagnostics` оборачивает listPages/recent/getTree, из develop-базы) И #538 search (`filterSearchResult`/`searchInPage`). Ничего не потеряно. - **Own delta vs свежий develop** = 20 файлов/+2154 (ожидаемый search-скоуп). **Гейт на отребейзенном дереве:** mcp node:test **888/888** (подтверждает read.ts-мердж + весь смерженный develop-контент), server tsc 0, search int-specs **25/25** на Docker pg (включая `search-lookup-explain` → индексы всё ещё используются на новой develop-базе). Дерево чисто. Ребейз чист, конфликт корректен, ядро не изменилось. Approve. (F1-F6 закрыты в round 2.)
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 17:45:00 +03:00
Sign in to join this conversation.