Инструмент search_in_page: внутристраничный поиск подстроки/regex для агента #330

Open
opened 2026-07-04 06:42:52 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

Агент (в первую очередь редакторские роли — Корректор, Фактчекер) ищет вхождения по странице вслепую: гоняет get_node по блокам подряд (14, 173, 187, 188…), сжигая тысячи токенов на перебор и рассуждения, чтобы найти, например, все слова без «ё», прямые кавычки вместо «ёлочек», «т.е.». Подходящего инструмента нет:

  • search — глобальный full-text по воркспейсу, не по одной странице, и не возвращает позиции внутри документа;
  • get_outline — только top-level блоки, без поиска по содержимому;
  • get_node — один блок по id.

Нужен инструмент «найди подстроку/паттерн внутри конкретной страницы и верни, где именно».

Ключевое архитектурное решение: клиентский поиск по ProseMirror JSON

Инструмент читает документ через существующий путь чтения (getPageRaw) и ищет в памяти по дереву ProseMirror. Следствия:

  • Не нужен серверный эндпоинт, не меняется схема БД/документа, не трогается зеркало схемы в packages/mcp/src/lib/.
  • Живёт в общем реестре SHARED_TOOL_SPECS (packages/mcp/src/tool-specs.ts) → автоматически появляется в обоих транспортах: внешний /mcp и встроенный AI-chat агент (роли работают именно во встроенном).
  • Поиск идёт по нормализованному plain-тексту контейнера, а не по markdown. Два бонуса:
    1. в JSON нет <span data-comment-id=…> — агент не спотыкается о якоря комментариев (в т.ч. resolved), которые в markdown-выводе get_page засоряют текст;
    2. склейка text-ранов решает «текст разбит инлайн-марками»: сильно меньше 8кб, где часть под comment-mark, находится как цельная строка.

Сигнатура

search_in_page(pageId, query, { regex?, caseSensitive?, limit? })
Параметр Тип Дефолт Смысл
pageId string страница
query string подстрока или regex-паттерн
regex bool false трактовать query как регулярку
caseSensitive bool false по умолчанию регистронезависимо
limit int 50 (max ~200) потолок числа совпадений в ответе

Движок — литерал + regex (согласовано): по умолчанию подстрока, regex:true включает регулярку. Для корректора/фактчекера regex даёт классы символов и границы слов (слова без «ё», прямые кавычки ["'], \bт\.е\.).

Формат ответа

{
  "total": 6,           // сколько всего найдено (даже если > limit)
  "truncated": false,   // true, если вернули не все (total > limit)
  "matches": [
    {
      "nodeId": "019f25b4-…",   // attrs.id ближайшего блока-контейнера,
                                //   ИЛИ "#<index>" для узлов без id (ячейки таблиц)
      "blockIndex": 173,        // индекс top-level блока (как в get_outline)
      "type": "paragraph",
      "before": "…считать что «есть 30% ",  // окно ~40 симв. до
      "match": "запаса",
      "after": "» — у нас не форм…"         // окно ~40 симв. после
    }
  ]
}

nodeId — тот же формат, что принимают get_node/patch_node/анкоринг комментария, поэтому агент из результата сразу идёт ставить точечный комментарий с suggestedText. before/after дают достаточно контекста, чтобы выбрать уникальное выделение (требование фичи suggestions — selection должен быть уникален).

Семантика обхода и nodeId

  • Рекурсивный спуск по дереву; единица поиска — текстовый контейнер (paragraph / heading / ячейка таблицы — узел, чьи дети содержат text-ноды).
  • Для контейнера склеиваем inline-текст в одну строку (переиспользуя логику blockPlainText из packages/mcp/src/lib/node-ops.ts) и ищем в ней — совпадение переживает границы марок.
  • nodeId = attrs.id контейнера; если его нет (ячейки/строки таблиц) — #<topLevelIndex> ближайшего top-level блока (семантика getNodeByRef, node-ops.ts).

Edge cases

  • Невалидный regex → понятная ошибка инструмента (не generic 500), чтобы агент исправил паттерн.
  • Анти-ReDoS: ограничить длину паттерна и размер входной строки; JS-regex не прерываемый, защищаемся ограничениями, а не таймаутом.
  • Пустой/слишком короткий query → отказ с пояснением.
  • total vs limit: всегда отдаём total и truncated — «сотня вхождений» не должна молча обрезаться.
  • Таблицы/списки: контекст из конкретной ячейки/пункта, а не из склейки всего блока.

Карта реализации (файлы)

Файл Роль
packages/mcp/src/lib/page-search.ts (new) чистая searchInDoc(doc, query, opts) + типы — тестируется изолированно, как comment-anchor.ts
packages/mcp/src/client.ts метод searchInPage(pageId, …) — читает getPageRaw, зовёт searchInDoc
packages/mcp/src/tool-specs.ts спека searchInPage (mcpName: search_in_page, inAppKey, description, buildShape)
packages/mcp/src/index.ts registerShared(...) + строка в SERVER_INSTRUCTIONS
apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts sharedTool(...) для встроенного агента
apps/server/src/core/ai-chat/tools/docmost-client.loader.ts сигнатура searchInPage в DocmostClientLike
packages/mcp/build/* пересобрать tsc (артефакты в git)
packages/mcp/test/unit/page-search.test.mjs (new) юнит-тесты

Промпт-интеграция

Без подсказки агент продолжит перебирать get_node. В комплекте: строка в SERVER_INSTRUCTIONS («ищешь вхождения по странице → search_in_page, а не перебор блоков») и одна фраза в промптах корректора и фактчекера («для однотипных проблем — ё, кавычки, единицы — сначала search_in_page, затем точечный комментарий на каждое вхождение»), с бампом версий этих ролей.

Тесты

literal и regex; case-sensitivity; совпадение через границу марок; nodeId для paragraph/heading/ячейки таблицы; limit/total/truncated; невалидный regex → ошибка; пустой query.

Вне скоупа v1

  • Замена текста (это edit_page_text).
  • Поиск по нескольким страницам (это search).
  • Поиск внутри resolved-комментариев.
  • Подсветка совпадений в UI.
# Проблема Агент (в первую очередь редакторские роли — Корректор, Фактчекер) ищет вхождения по странице **вслепую**: гоняет `get_node` по блокам подряд (14, 173, 187, 188…), сжигая тысячи токенов на перебор и рассуждения, чтобы найти, например, все слова без «ё», прямые кавычки вместо «ёлочек», «т.е.». Подходящего инструмента нет: - `search` — глобальный full-text по **воркспейсу**, не по одной странице, и не возвращает позиции внутри документа; - `get_outline` — только top-level блоки, без поиска по содержимому; - `get_node` — один блок по id. Нужен инструмент «найди подстроку/паттерн внутри конкретной страницы и верни, где именно». # Ключевое архитектурное решение: клиентский поиск по ProseMirror JSON Инструмент читает документ через существующий путь чтения (`getPageRaw`) и ищет **в памяти по дереву ProseMirror**. Следствия: - **Не нужен серверный эндпоинт**, не меняется схема БД/документа, не трогается зеркало схемы в `packages/mcp/src/lib/`. - Живёт в общем реестре `SHARED_TOOL_SPECS` (`packages/mcp/src/tool-specs.ts`) → автоматически появляется **в обоих транспортах**: внешний `/mcp` и встроенный AI-chat агент (роли работают именно во встроенном). - **Поиск идёт по нормализованному plain-тексту контейнера, а не по markdown.** Два бонуса: 1. в JSON нет `<span data-comment-id=…>` — агент не спотыкается о якоря комментариев (в т.ч. resolved), которые в markdown-выводе `get_page` засоряют текст; 2. склейка text-ранов решает «текст разбит инлайн-марками»: `сильно меньше 8кб`, где часть под comment-mark, находится как цельная строка. # Сигнатура ``` search_in_page(pageId, query, { regex?, caseSensitive?, limit? }) ``` | Параметр | Тип | Дефолт | Смысл | | --- | --- | --- | --- | | `pageId` | string | — | страница | | `query` | string | — | подстрока или regex-паттерн | | `regex` | bool | `false` | трактовать `query` как регулярку | | `caseSensitive` | bool | `false` | по умолчанию регистронезависимо | | `limit` | int | `50` (max ~200) | потолок числа совпадений в ответе | Движок — **литерал + regex** (согласовано): по умолчанию подстрока, `regex:true` включает регулярку. Для корректора/фактчекера regex даёт классы символов и границы слов (слова без «ё», прямые кавычки `["']`, `\bт\.е\.`). # Формат ответа ```jsonc { "total": 6, // сколько всего найдено (даже если > limit) "truncated": false, // true, если вернули не все (total > limit) "matches": [ { "nodeId": "019f25b4-…", // attrs.id ближайшего блока-контейнера, // ИЛИ "#<index>" для узлов без id (ячейки таблиц) "blockIndex": 173, // индекс top-level блока (как в get_outline) "type": "paragraph", "before": "…считать что «есть 30% ", // окно ~40 симв. до "match": "запаса", "after": "» — у нас не форм…" // окно ~40 симв. после } ] } ``` `nodeId` — тот же формат, что принимают `get_node`/`patch_node`/анкоринг комментария, поэтому агент из результата сразу идёт ставить точечный комментарий с `suggestedText`. `before`/`after` дают достаточно контекста, чтобы выбрать **уникальное** выделение (требование фичи suggestions — selection должен быть уникален). # Семантика обхода и nodeId - Рекурсивный спуск по дереву; единица поиска — **текстовый контейнер** (paragraph / heading / ячейка таблицы — узел, чьи дети содержат text-ноды). - Для контейнера склеиваем inline-текст в одну строку (переиспользуя логику `blockPlainText` из `packages/mcp/src/lib/node-ops.ts`) и ищем в ней — совпадение переживает границы марок. - `nodeId` = `attrs.id` контейнера; если его нет (ячейки/строки таблиц) — `#<topLevelIndex>` ближайшего top-level блока (семантика `getNodeByRef`, `node-ops.ts`). # Edge cases - **Невалидный regex** → понятная ошибка инструмента (не generic 500), чтобы агент исправил паттерн. - **Анти-ReDoS**: ограничить длину паттерна и размер входной строки; JS-regex не прерываемый, защищаемся ограничениями, а не таймаутом. - **Пустой/слишком короткий `query`** → отказ с пояснением. - **`total` vs `limit`**: всегда отдаём `total` и `truncated` — «сотня вхождений» не должна молча обрезаться. - **Таблицы/списки**: контекст из конкретной ячейки/пункта, а не из склейки всего блока. # Карта реализации (файлы) | Файл | Роль | | --- | --- | | `packages/mcp/src/lib/page-search.ts` *(new)* | чистая `searchInDoc(doc, query, opts)` + типы — тестируется изолированно, как `comment-anchor.ts` | | `packages/mcp/src/client.ts` | метод `searchInPage(pageId, …)` — читает `getPageRaw`, зовёт `searchInDoc` | | `packages/mcp/src/tool-specs.ts` | спека `searchInPage` (`mcpName: search_in_page`, `inAppKey`, description, `buildShape`) | | `packages/mcp/src/index.ts` | `registerShared(...)` + строка в `SERVER_INSTRUCTIONS` | | `apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts` | `sharedTool(...)` для встроенного агента | | `apps/server/src/core/ai-chat/tools/docmost-client.loader.ts` | сигнатура `searchInPage` в `DocmostClientLike` | | `packages/mcp/build/*` | пересобрать `tsc` (артефакты в git) | | `packages/mcp/test/unit/page-search.test.mjs` *(new)* | юнит-тесты | # Промпт-интеграция Без подсказки агент продолжит перебирать `get_node`. В комплекте: строка в `SERVER_INSTRUCTIONS` («ищешь вхождения по странице → `search_in_page`, а не перебор блоков») и одна фраза в промптах корректора и фактчекера («для однотипных проблем — ё, кавычки, единицы — сначала `search_in_page`, затем точечный комментарий на каждое вхождение»), с бампом версий этих ролей. # Тесты literal и regex; case-sensitivity; совпадение через границу марок; `nodeId` для paragraph/heading/ячейки таблицы; `limit`/`total`/`truncated`; невалидный regex → ошибка; пустой query. # Вне скоупа v1 - Замена текста (это `edit_page_text`). - Поиск по нескольким страницам (это `search`). - Поиск внутри resolved-комментариев. - Подсветка совпадений в UI.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#330