Предложения исправлений в комментариях агентов: suggestedText + кнопка «Применить» #315

Closed
opened 2026-07-03 18:18:20 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Агент (MCP create_comment или AI-чат createComment) при создании inline-комментария может приложить предлагаемую замену выделенного текста. Человек в панели комментариев видит блок «было → стало» и кнопку «Применить». Нажатие заменяет заякоренный фрагмент страницы на предложенный текст — только если фрагмент никто не изменил с момента создания предложения. Human-in-the-loop by design: агент предлагает, человек применяет.

Формат предложения

Не unified diff и не патч — пара строк с replace-семантикой, тот же паттерн, что в edit_page_text:

create_comment({
  pageId: "…",
  content: "Тут фактическая ошибка: релиз был в 2024, а не в 2023", // why (Markdown)
  selection: "Первый релиз вышел в 2023 году",     // what: exact anchor text == old text
  suggestedText: "Первый релиз вышел в 2024 году", // replacement, plain text
})
  • selection (существующее поле, ≤250) — одновременно якорь и «старая» сторона.
  • suggestedText (новое, plain text, ≤2000) — полная замена фрагмента. Обязан отличаться от selection, пустая строка запрещена.
  • Красно-зелёный дифф в UI — производная величина, рендерится клиентом из этой пары; в хранение/протокол дифф не попадает.
  • Ограничения v1: один непрерывный фрагмент в одном блоке; без inline-форматирования (замена вставляется одним текстовым раном); правка «в трёх местах» = три комментария.

Ключевая механика: привязка к выделению, не к тексту

Применение не ищет текст в документе — замена идёт по марке comment с данным commentId в Yjs-документе:

создание (агент):  текст selection --[поиск, требуем уникальность]--> марка commentId
жизнь:             марка едет вместе с текстом при любых правках вокруг
применение (клик): марка commentId --> текст под ней == selection? --> заменить на suggestedText
  • Марка приклеена к символам: правки выше/ниже по документу смещают её вместе с текстом, применение остаётся корректным.
  • Дубликаты той же фразы в других местах (в т.ч. появившиеся позже) на применение не влияют.
  • Проверка «никто не изменил» = текст под маркой равен selection, позиция не важна.

Неоднозначность существует только в момент создания марки агентом (у агента нет курсора, якорь задаётся текстом; applyAnchorInDoc сейчас молча берёт первое вхождение — packages/mcp/src/lib/comment-anchor.ts). Для обычного комментария цена ошибки — марка не там; с suggestedText клик «Применить» отредактировал бы не то место. Поэтому новое правило: при наличии suggestedText вхождение selection обязано быть уникальным в документе. ≥2 вхождений → отказ с подсказкой агенту расширить selection контекстом (бюджет 250 символов). Проверка и в пре-чеке (canAnchorInDoc по getPageJson), и — авторитетно — в живом anchor-шаге внутри mutatePageContent (abort + rollback комментария, тот же механизм, что при «selection not found»). Комментарии без suggestedText сохраняют текущее поведение (первое вхождение). Человеческие комментарии из UI не затронуты — там якорь позиционный (yjsSelection). Вариант occurrence: N отвергнут: LLM плохо считают вхождения.

Почему применение — на сервере, а не в браузере

Клиентский вариант (Tiptap-транзакция) отвергнут: два одновременных «Применить» оба проходят локальную проверку, Yjs мёржит обе вставки → испорченный текст; проверка шла бы против возможно устаревшего локального дока. Серверный вариант: check-and-replace атомарно внутри connection.transact() на инстансе-владельце документа. Инфраструктура готова: кастомные события CollaborationHandler (паттерн setCommentMark/resolveCommentMark), а RedisSyncExtension.handleEvent возвращает результат хендлера кросс-процессно (customEventStart/customEventComplete + replyId) — API-сервер получает вердикт из collab-процесса.

Дизайн по слоям

1. БД — миграция 2026….-comment-suggestions.ts

Только добавление колонок в comments (+ migration:codegen для db.d.ts; таймстемп позже последней применённой миграции на develop):

  • suggested_text text NULL
  • suggestion_applied_at timestamptz NULL
  • suggestion_applied_by_id uuid NULL REFERENCES users(id)

2. Сервер — создание

  • CreateCommentDto + @IsOptional() @IsString() @MaxLength(2000) suggestedText.
  • Валидация в CommentService.create (apps/server/src/core/comment/comment.service.ts): suggestedText допустим только для top-level inline-комментария с selection; обязан отличаться от selection (иначе применение неотличимо от «уже применено»).

3. Сервер — применение

Эндпоинт POST /comments/apply-suggestion ({ commentId }):

  1. Контроллер: комментарий top-level, есть suggestedText, не применён, тред не резолвлен; права — pageAccessService.validateCanEdit (не validateCanComment: применение меняет текст страницы).
  2. Новое кастомное событие applyCommentSuggestion в collaboration.handler.ts + хелпер в yjs.util.ts:
// yjs.util.ts — same walk pattern as updateYjsMarkAttribute()
export function replaceYjsMarkedText(
  fragment: Y.XmlFragment,
  commentId: string,
  expectedText: string, // stored comment.selection
  newText: string,      // stored comment.suggestedText
): { applied: boolean; currentText: string | null } {
  // 1. Collect XmlText delta segments whose `comment` mark has this commentId.
  // 2. Reject when: no segments (anchor deleted), segments in >1 block
  //    (paragraph split), or unmarked text interleaved inside the range.
  // 3. Join segment texts; joined !== expectedText -> { applied: false,
  //    currentText: joined } — the "someone changed it" verdict.
  // 4. Else delete segments and insert newText at the first segment offset,
  //    re-attaching the SAME `comment` mark attrs — thread stays anchored
  //    to the new text.
}
  1. CommentService.applySuggestion разбирает вердикт:
    • applied: truesuggestionAppliedAt/ById, авторезолв треда через существующий resolveComment(comment, true, user) (марка получает resolved=true, уведомления штатно), ws commentUpdated, audit COMMENT_SUGGESTION_APPLIED;
    • applied: false и currentText === suggestedTextидемпотентная ветка «уже применено» (лечит двойной клик, гонку двух пользователей и падение между мутацией дока и записью БД): дописать applied-поля, вернуть успех;
    • иначе → 409 Conflict с конкретным сообщением и currentText в теле.

Открытые редакторы получают замену и резолв марки через штатный Yjs-синк.

4. Клиент

  • IComment + suggestedText, suggestionAppliedAt, suggestionAppliedById (apps/client/src/features/comment/types/comment.types.ts).
  • comment-list-item.tsx: под цитатой selection — дифф-блок (старый текст зачёркнут/красный фон → новый зелёным; опционально пословная LCS-подсветка). Кнопка «Применить» видима при: есть suggestedText, !suggestionAppliedAt, !resolvedAt, page.permissions.canEdit (прокинуть из comment-list-with-tabs.tsx, где он уже есть). После применения — бейдж «Применено».
  • useApplySuggestionMutation в comment-query.ts по образцу resolve-мутации; на 409 — конкретное уведомление с актуальным текстом из ответа (конвенция форка об ошибках — никаких generic-сообщений).

5. Агентские инструменты

  • MCP create_comment (packages/mcp/src/index.ts, client.ts): опциональный suggestedText (zod, ≤2000, требует selection, запрещён для reply) → payload /comments/create; описание тула объясняет семантику. comment-anchor.ts: countAnchorMatches + строгая уникальность при suggestedText. filterComment/list_comments отдают новые поля.
  • AI-чат createComment (ai-chat-tools.service.ts) + сигнатура в DocmostClientLike (docmost-client.loader.ts) — зеркально; проходит через тот же DocmostClient.createComment, правило уникальности наследуется.
  • Схема документа не меняется (марка comment та же) — синхронизация зеркала схемы в packages/mcp/src/lib/ не нужна.

Крайние случаи

Случай Поведение
Текст под маркой изменён 409 + текущий текст, кнопка остаётся (вдруг откатят)
Марка удалена / абзац разбит Enter / вклинился чужой текст «изменён» → 409
Двойной клик / гонка двух пользователей transact() сериализует; второй → идемпотентная ветка «уже применено»
Падение между мутацией дока и записью БД следующий клик самоизлечивается через currentText === suggestedText
Резолвнутый тред кнопка скрыта; после reopen вернётся, сработает только при совпадении текста
Дубликат selection при создании с suggestedText отказ create_comment, агенту подсказка расширить контекст
COLLAB_DISABLE_REDIS сейчас handleYjsEvent молча возвращает undefined — для apply недопустимо: локальный fallback-вызов хендлера, при невозможности — жёсткая ошибка, не тихий no-op

Вне скоупа v1

  • Создание предложений человеком из UI.
  • Предложение-удаление (пустая замена).
  • MCP-тул «применить предложение» (агент предлагает — человек решает).
  • Показ предложения в hover-превью комментария.
  • Inline-форматирование в замене.

План работ

  1. Миграция + codegen db.d.ts.
  2. yjs.util.ts: replaceYjsMarkedText + юнит-тесты в yjs.util.spec.ts.
  3. collaboration.handler.ts: событие applyCommentSuggestion; fallback в collaboration.gateway.ts.
  4. Сервер: DTO, CommentService.create (хранение) + applySuggestion, контроллер /comments/apply-suggestion, audit-событие, тесты сервиса.
  5. Клиент: типы, дифф-блок + кнопка/бейдж, мутация, обработка 409, тесты comment-list-item.test.tsx.
  6. MCP + AI-чат: параметр suggestedText, countAnchorMatches + строгая уникальность, filterComment, contract-тест client-host-contract, тесты на неоднозначный якорь.

Порядок: 1→2→3→4 последовательно, 5 и 6 параллельно после 4.

# Суть Агент (MCP `create_comment` или AI-чат `createComment`) при создании inline-комментария может приложить **предлагаемую замену** выделенного текста. Человек в панели комментариев видит блок «было → стало» и кнопку **«Применить»**. Нажатие заменяет заякоренный фрагмент страницы на предложенный текст — **только если фрагмент никто не изменил** с момента создания предложения. Human-in-the-loop by design: агент предлагает, человек применяет. ## Формат предложения Не unified diff и не патч — **пара строк с replace-семантикой**, тот же паттерн, что в `edit_page_text`: ```typescript create_comment({ pageId: "…", content: "Тут фактическая ошибка: релиз был в 2024, а не в 2023", // why (Markdown) selection: "Первый релиз вышел в 2023 году", // what: exact anchor text == old text suggestedText: "Первый релиз вышел в 2024 году", // replacement, plain text }) ``` - `selection` (существующее поле, ≤250) — одновременно якорь и «старая» сторона. - `suggestedText` (новое, plain text, ≤2000) — полная замена фрагмента. Обязан отличаться от `selection`, пустая строка запрещена. - Красно-зелёный дифф в UI — производная величина, рендерится клиентом из этой пары; в хранение/протокол дифф не попадает. - Ограничения v1: один непрерывный фрагмент в одном блоке; без inline-форматирования (замена вставляется одним текстовым раном); правка «в трёх местах» = три комментария. ## Ключевая механика: привязка к выделению, не к тексту Применение **не ищет текст в документе** — замена идёт по марке `comment` с данным `commentId` в Yjs-документе: ``` создание (агент): текст selection --[поиск, требуем уникальность]--> марка commentId жизнь: марка едет вместе с текстом при любых правках вокруг применение (клик): марка commentId --> текст под ней == selection? --> заменить на suggestedText ``` - Марка приклеена к символам: правки выше/ниже по документу смещают её вместе с текстом, применение остаётся корректным. - Дубликаты той же фразы в других местах (в т.ч. появившиеся позже) на применение не влияют. - Проверка «никто не изменил» = текст под маркой равен `selection`, позиция не важна. **Неоднозначность существует только в момент создания** марки агентом (у агента нет курсора, якорь задаётся текстом; `applyAnchorInDoc` сейчас молча берёт первое вхождение — `packages/mcp/src/lib/comment-anchor.ts`). Для обычного комментария цена ошибки — марка не там; с `suggestedText` клик «Применить» отредактировал бы **не то место**. Поэтому новое правило: **при наличии `suggestedText` вхождение `selection` обязано быть уникальным в документе**. ≥2 вхождений → отказ с подсказкой агенту расширить selection контекстом (бюджет 250 символов). Проверка и в пре-чеке (`canAnchorInDoc` по `getPageJson`), и — авторитетно — в живом anchor-шаге внутри `mutatePageContent` (abort + rollback комментария, тот же механизм, что при «selection not found»). Комментарии без `suggestedText` сохраняют текущее поведение (первое вхождение). Человеческие комментарии из UI не затронуты — там якорь позиционный (`yjsSelection`). Вариант `occurrence: N` отвергнут: LLM плохо считают вхождения. ## Почему применение — на сервере, а не в браузере Клиентский вариант (Tiptap-транзакция) отвергнут: два одновременных «Применить» оба проходят локальную проверку, Yjs мёржит обе вставки → испорченный текст; проверка шла бы против возможно устаревшего локального дока. Серверный вариант: check-and-replace **атомарно внутри `connection.transact()`** на инстансе-владельце документа. Инфраструктура готова: кастомные события `CollaborationHandler` (паттерн `setCommentMark`/`resolveCommentMark`), а `RedisSyncExtension.handleEvent` возвращает результат хендлера кросс-процессно (`customEventStart`/`customEventComplete` + `replyId`) — API-сервер получает вердикт из collab-процесса. # Дизайн по слоям ## 1. БД — миграция `2026….-comment-suggestions.ts` Только добавление колонок в `comments` (+ `migration:codegen` для `db.d.ts`; таймстемп позже последней применённой миграции на develop): - `suggested_text text NULL` - `suggestion_applied_at timestamptz NULL` - `suggestion_applied_by_id uuid NULL REFERENCES users(id)` ## 2. Сервер — создание - `CreateCommentDto` + `@IsOptional() @IsString() @MaxLength(2000) suggestedText`. - Валидация в `CommentService.create` (`apps/server/src/core/comment/comment.service.ts`): `suggestedText` допустим только для top-level inline-комментария с `selection`; обязан отличаться от `selection` (иначе применение неотличимо от «уже применено»). ## 3. Сервер — применение Эндпоинт `POST /comments/apply-suggestion` (`{ commentId }`): 1. Контроллер: комментарий top-level, есть `suggestedText`, не применён, тред не резолвлен; права — **`pageAccessService.validateCanEdit`** (не `validateCanComment`: применение меняет текст страницы). 2. Новое кастомное событие `applyCommentSuggestion` в `collaboration.handler.ts` + хелпер в `yjs.util.ts`: ```typescript // yjs.util.ts — same walk pattern as updateYjsMarkAttribute() export function replaceYjsMarkedText( fragment: Y.XmlFragment, commentId: string, expectedText: string, // stored comment.selection newText: string, // stored comment.suggestedText ): { applied: boolean; currentText: string | null } { // 1. Collect XmlText delta segments whose `comment` mark has this commentId. // 2. Reject when: no segments (anchor deleted), segments in >1 block // (paragraph split), or unmarked text interleaved inside the range. // 3. Join segment texts; joined !== expectedText -> { applied: false, // currentText: joined } — the "someone changed it" verdict. // 4. Else delete segments and insert newText at the first segment offset, // re-attaching the SAME `comment` mark attrs — thread stays anchored // to the new text. } ``` 3. `CommentService.applySuggestion` разбирает вердикт: - `applied: true` → `suggestionAppliedAt/ById`, **авторезолв треда** через существующий `resolveComment(comment, true, user)` (марка получает `resolved=true`, уведомления штатно), ws `commentUpdated`, audit `COMMENT_SUGGESTION_APPLIED`; - `applied: false` и `currentText === suggestedText` → **идемпотентная ветка «уже применено»** (лечит двойной клик, гонку двух пользователей и падение между мутацией дока и записью БД): дописать applied-поля, вернуть успех; - иначе → `409 Conflict` с конкретным сообщением и `currentText` в теле. Открытые редакторы получают замену и резолв марки через штатный Yjs-синк. ## 4. Клиент - `IComment` + `suggestedText`, `suggestionAppliedAt`, `suggestionAppliedById` (`apps/client/src/features/comment/types/comment.types.ts`). - `comment-list-item.tsx`: под цитатой selection — дифф-блок (старый текст зачёркнут/красный фон → новый зелёным; опционально пословная LCS-подсветка). Кнопка «Применить» видима при: есть `suggestedText`, `!suggestionAppliedAt`, `!resolvedAt`, `page.permissions.canEdit` (прокинуть из `comment-list-with-tabs.tsx`, где он уже есть). После применения — бейдж «Применено». - `useApplySuggestionMutation` в `comment-query.ts` по образцу resolve-мутации; на 409 — конкретное уведомление с актуальным текстом из ответа (конвенция форка об ошибках — никаких generic-сообщений). ## 5. Агентские инструменты - MCP `create_comment` (`packages/mcp/src/index.ts`, `client.ts`): опциональный `suggestedText` (zod, ≤2000, требует `selection`, запрещён для reply) → payload `/comments/create`; описание тула объясняет семантику. `comment-anchor.ts`: `countAnchorMatches` + строгая уникальность при `suggestedText`. `filterComment`/`list_comments` отдают новые поля. - AI-чат `createComment` (`ai-chat-tools.service.ts`) + сигнатура в `DocmostClientLike` (`docmost-client.loader.ts`) — зеркально; проходит через тот же `DocmostClient.createComment`, правило уникальности наследуется. - Схема документа не меняется (марка `comment` та же) — синхронизация зеркала схемы в `packages/mcp/src/lib/` не нужна. # Крайние случаи | Случай | Поведение | |---|---| | Текст под маркой изменён | 409 + текущий текст, кнопка остаётся (вдруг откатят) | | Марка удалена / абзац разбит Enter / вклинился чужой текст | «изменён» → 409 | | Двойной клик / гонка двух пользователей | `transact()` сериализует; второй → идемпотентная ветка «уже применено» | | Падение между мутацией дока и записью БД | следующий клик самоизлечивается через `currentText === suggestedText` | | Резолвнутый тред | кнопка скрыта; после reopen вернётся, сработает только при совпадении текста | | Дубликат selection при создании с suggestedText | отказ create_comment, агенту подсказка расширить контекст | | `COLLAB_DISABLE_REDIS` | сейчас `handleYjsEvent` молча возвращает `undefined` — для apply недопустимо: локальный fallback-вызов хендлера, при невозможности — жёсткая ошибка, не тихий no-op | # Вне скоупа v1 - Создание предложений человеком из UI. - Предложение-удаление (пустая замена). - MCP-тул «применить предложение» (агент предлагает — человек решает). - Показ предложения в hover-превью комментария. - Inline-форматирование в замене. # План работ 1. Миграция + codegen `db.d.ts`. 2. `yjs.util.ts`: `replaceYjsMarkedText` + юнит-тесты в `yjs.util.spec.ts`. 3. `collaboration.handler.ts`: событие `applyCommentSuggestion`; fallback в `collaboration.gateway.ts`. 4. Сервер: DTO, `CommentService.create` (хранение) + `applySuggestion`, контроллер `/comments/apply-suggestion`, audit-событие, тесты сервиса. 5. Клиент: типы, дифф-блок + кнопка/бейдж, мутация, обработка 409, тесты `comment-list-item.test.tsx`. 6. MCP + AI-чат: параметр `suggestedText`, `countAnchorMatches` + строгая уникальность, `filterComment`, contract-тест `client-host-contract`, тесты на неоднозначный якорь. Порядок: 1→2→3→4 последовательно, 5 и 6 параллельно после 4.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#315