[feature][ai-chat] Уведомлять агента о правках пользователя в открытой странице (per-turn diff в системном промпте) #274

Open
opened 2026-07-01 18:27:39 +03:00 by vvzvlad · 0 comments
Owner

Проблема

Агент в AI-chat не знает, что пользователь вручную правил открытую страницу между его ответами, и работает от своей устаревшей копии.

Сценарий:

  1. Пользователь: «напиши текст X» → агент пишет страницу.
  2. Пользователь руками правит страницу в редакторе.
  3. Пользователь: «переделай шапку».
  4. Агент переделывает шапку, но вставляет свою прошлую версию тела — правки пользователя из п.2 затираются, потому что агент не в курсе, что страница менялась.

Сейчас лечится только вручную («перечитай страницу»). Нужно автоматически сообщать агенту, что открытая страница была изменена пользователем с момента его последнего хода.

Причина (по коду)

  • Контекст модели пересобирается из БД на каждый ход: AiChatMessageRepo.findAllByChatrowToUiMessageconvertToModelMessages (apps/server/src/core/ai-chat/ai-chat.service.ts:366-379). В транскрипте лежат прошлые tool-выводы getPage (Markdown-контент страницы), и модель реконструирует страницу из них.
  • Никакого сигнала «пользователь правил страницу между ходами» в контекст не поступает. Открытая страница приходит от клиента как openPage и валидируется в resolveOpenPageContext (ai-chat.service.ts:271-296), но её контент не сравнивается с тем, что агент видел раньше.

Решение

Per-turn эфемерная заметка в системном промпте с unified-diff в Markdown — прямой близнец уже существующего INTERRUPT_NOTE (apps/server/src/core/ai-chat/ai-chat.prompt.ts:67-73, флаг interrupted): вставляется в context-секцию внутри safety-сэндвича, живёт один ход и самоочищается, потому что промпт строится заново каждый ход.

Формат — Markdown (агент и читает getPage, и пишет updatePageContent страницу в Markdown), оба конца диффа рендерятся одним и тем же путём (exportPageMarkdown), иначе дифф зашумится форматированием.

Референс диффа: «состояние на конец прошлого хода»

База для диффа — не «что агент прочитал», а состояние страницы на конец прошлого хода агента (снимок). Тогда diff(снимок, текущее) = ровно то, что наменял кто-то другой между ходами, а собственные правки агента исключ��ются по построению (они уже вшиты в снимок). Провенанс-фильтры (pages.last_updated_source/last_updated_ai_chat_id) для корректности не нужны — остаются опциональным быстрым guard'ом.

Жизненный цикл:

  • Начало хода (детекция): открыта страница → берём снимок (chatId, pageId); если snapshot.pageUpdatedAt !== page.updatedAt → рендерим текущий Markdown, считаем дифф; непустой → взводим флаг + текст диффа для промпта.
  • Конец хода (onFinish): upsert снимка = текущий Markdown после всех правок агента. Это и есть self-clearing: тот же пользовательский правок на следующем ходу уже не повторится (снимок догнал текущее). Здесь же сидинг снимка на первом ходу.

Пример заметки (в context-секции системного промпта):

<page_changed>
The current page "Article X" was edited by the user after your last response.
Treat your earlier copy of this page as stale — the unified diff below is the
source of truth for what changed. Re-read with getPage if you need full context.

@@ header @@
- Old heading you wrote
+ New heading the user set
</page_changed>

Изменения по файлам

  1. Миграция (новая) database/migrations/<ts>-ai-chat-page-snapshot.ts (по образцу 20260409T132415-ai-chat.ts):

    ai_chat_page_snapshots(
      id pk,
      chat_id      fk → ai_chats ON DELETE CASCADE,
      page_id      fk → pages    ON DELETE CASCADE,
      workspace_id,
      content_md      text,
      page_updated_at timestamptz,
      content_hash    varchar null,
      created_at, updated_at,
      UNIQUE(chat_id, page_id))
    

    Плюс объявить таблицу в database/types/db.d.ts и entity.types.

  2. Репозиторий (новый) database/repos/ai-chat/ai-chat-page-snapshot.repo.ts: findByChatPage(chatId, pageId, wsId) + upsert(...). Рядом .spec.ts.

  3. Чистый util диффа (новый) core/ai-chat/page-change/page-change.util.ts: computePageChange(snapshotMd, currentMd) → { changed, diff } — unified diff, нормализация пробелов, капа по размеру (≈4–8 КБ, дальше «diff truncated — use getPage to read the full current page»). Переиспользовать существующую diff-зависимость (та, что за diffPageVersions/dry-run), новую не добавлять. Юнит-тестируемо → .spec.ts.

  4. Промпт core/ai-chat/ai-chat.prompt.ts — близнец INTERRUPT_NOTE:

    • в BuildSystemPromptInput добавить pageChanged?: { title: string; diff: string } | null;
    • константа PAGE_CHANGED_NOTE + блок <page_changed> в context-секции (внутри safety-сэндвича, как interrupted);
    • тесты в ai-chat.prompt.spec.ts.
  5. resolveOpenPageContext (ai-chat.service.ts:271-296) — расширить возврат на updatedAt (строка page уже загружена).

  6. Сборка хода (ai-chat.service.ts:366-462) — вычислить pageChanged по циклу детекции и передать в buildSystemPrompt рядом с interrupted. Текущий Markdown брать тем же путём, что тулы (client.exportPageMarkdown/getPage через tools/docmost-client.loader.ts).

  7. Обновление снимка — в терминальном onFinish (рядом с closeExternalClients/finalize, ai-chat.service.ts:478-531): upsert снимка текущим Markdown + updatedAt; сидинг на первом ходу.

Краевые случаи

  • Страница не открыта → логика снимков не запускается.
  • Первый ход / нет снимка → только сидинг, заметки нет.
  • updatedAt не изменился → быстрый путь, без рендера и диффа.
  • Дифф после нормализации пустой (только пробелы/форматирование) → заметки нет.
  • Большой дифф → капа + подсказка перечитать через getPage.
  • Страница удалена в ходе → снимок не пишем.
  • Смена/несколько страниц → снимок по (chat, page), свой ряд на страницу.
  • Гонка (правка пользователя во время хода) → поймаем на следующем ходу.

Тесты

  • page-change.util.spec.ts: чистая функция диффа (изменение/пусто/нормализация/капа).
  • ai-chat.prompt.spec.ts: рендер блока <page_changed> по флагу и его отсутствие без флага.
  • ai-chat-page-snapshot.repo.spec.ts: upsert/find.
  • Сервисный lifecycle-тест: снимок обновляется на onFinish; заметка появляется ровно на один ход и самоочищается.

Вне scope / на будущее

  • B1 (optimistic-lock на запись) — жёсткая гарантия «старый текст не затрёт свежий»: write-тул отклоняет правку от устаревшей базы (как это делает сам Claude Code — «file modified since read»). Заметка из этого issue — advisory-слой; B1 клеим как hardening, если на практике агент будет игнорировать заметку. Требует версионирования write-тулов, поэтому отложено.
  • Опциональный провенанс-guard (last_updated_source/last_updated_ai_chat_id) как быстрый пре-чек.
  • Возможная связь с #247 (слепки истории по Save/простою + матчинг с агентами).
## Проблема Агент в AI-chat не знает, что пользователь вручную правил открытую страницу между его ответами, и работает от своей устаревшей копии. Сценарий: 1. Пользователь: «напиши текст X» → агент пишет страницу. 2. Пользователь руками правит страницу в редакторе. 3. Пользователь: «переделай шапку». 4. Агент переделывает шапку, но вставляет свою **прошлую** версию тела — правки пользователя из п.2 затираются, потому что агент не в курсе, что страница менялась. Сейчас лечится только вручную («перечитай страницу»). Нужно автоматически сообщать агенту, что открытая страница была изменена пользователем с момента его последнего хода. ## Причина (по коду) - Контекст модели пересобирается из БД **на каждый ход**: `AiChatMessageRepo.findAllByChat` → `rowToUiMessage` → `convertToModelMessages` (`apps/server/src/core/ai-chat/ai-chat.service.ts:366-379`). В транскрипте лежат прошлые tool-выводы `getPage` (Markdown-контент страницы), и модель реконструирует страницу из них. - Никакого сигнала «пользователь правил страницу между ходами» в контекст не поступает. Открытая страница приходит от клиента как `openPage` и валидируется в `resolveOpenPageContext` (`ai-chat.service.ts:271-296`), но её контент не сравнивается с тем, что агент видел раньше. ## Решение Per-turn **эфемерная заметка** в системном промпте с unified-diff в Markdown — прямой близнец уже существующего `INTERRUPT_NOTE` (`apps/server/src/core/ai-chat/ai-chat.prompt.ts:67-73`, флаг `interrupted`): вставляется в context-секцию внутри safety-сэндвича, живёт один ход и самоочищается, потому что промпт строится заново каждый ход. Формат — **Markdown** (агент и читает `getPage`, и пишет `updatePageContent` страницу в Markdown), оба конца диффа рендерятся **одним и тем же путём** (`exportPageMarkdown`), иначе дифф зашумится форматированием. ### Референс диффа: «состояние на конец прошлого хода» База для диффа — не «что агент прочитал», а состояние страницы **на конец прошлого хода агента** (снимок). Тогда `diff(снимок, текущее)` = ровно то, что наменял кто-то другой между ходами, а собственные правки агента исключ��ются по построению (они уже вшиты в снимок). Провенанс-фильтры (`pages.last_updated_source`/`last_updated_ai_chat_id`) для корректности не нужны — остаются опциональным быстрым guard'ом. Жизненный цикл: - **Начало хода (детекция):** открыта страница → берём снимок `(chatId, pageId)`; если `snapshot.pageUpdatedAt !== page.updatedAt` → рендерим текущий Markdown, считаем дифф; непустой → взводим флаг + текст диффа для промпта. - **Конец хода (`onFinish`):** upsert снимка = текущий Markdown после всех правок агента. Это и есть self-clearing: тот же пользовательский правок на следующем ходу уже не повторится (снимок догнал текущее). Здесь же сидинг снимка на первом ходу. Пример заметки (в context-секции системного промпта): ``` <page_changed> The current page "Article X" was edited by the user after your last response. Treat your earlier copy of this page as stale — the unified diff below is the source of truth for what changed. Re-read with getPage if you need full context. @@ header @@ - Old heading you wrote + New heading the user set </page_changed> ``` ## Изменения по файлам 1. **Миграция (новая)** `database/migrations/<ts>-ai-chat-page-snapshot.ts` (по образцу `20260409T132415-ai-chat.ts`): ``` ai_chat_page_snapshots( id pk, chat_id fk → ai_chats ON DELETE CASCADE, page_id fk → pages ON DELETE CASCADE, workspace_id, content_md text, page_updated_at timestamptz, content_hash varchar null, created_at, updated_at, UNIQUE(chat_id, page_id)) ``` Плюс объявить таблицу в `database/types/db.d.ts` и `entity.types`. 2. **Репозиторий (новый)** `database/repos/ai-chat/ai-chat-page-snapshot.repo.ts`: `findByChatPage(chatId, pageId, wsId)` + `upsert(...)`. Рядом `.spec.ts`. 3. **Чистый util диффа (новый)** `core/ai-chat/page-change/page-change.util.ts`: `computePageChange(snapshotMd, currentMd) → { changed, diff }` — unified diff, нормализация пробелов, **капа по размеру** (≈4–8 КБ, дальше «diff truncated — use getPage to read the full current page»). Переиспользовать существующую diff-зависимость (та, что за `diffPageVersions`/dry-run), новую не добавлять. Юнит-тестируемо → `.spec.ts`. 4. **Промпт** `core/ai-chat/ai-chat.prompt.ts` — близнец `INTERRUPT_NOTE`: - в `BuildSystemPromptInput` добавить `pageChanged?: { title: string; diff: string } | null`; - константа `PAGE_CHANGED_NOTE` + блок `<page_changed>` в context-секции (внутри safety-сэндвича, как `interrupted`); - тесты в `ai-chat.prompt.spec.ts`. 5. **`resolveOpenPageContext`** (`ai-chat.service.ts:271-296`) — расширить возврат на `updatedAt` (строка `page` уже загружена). 6. **Сборка хода** (`ai-chat.service.ts:366-462`) — вычислить `pageChanged` по циклу детекции и передать в `buildSystemPrompt` рядом с `interrupted`. Текущий Markdown брать тем же путём, что тулы (`client.exportPageMarkdown`/`getPage` через `tools/docmost-client.loader.ts`). 7. **Обновление снимка** — в терминальном `onFinish` (рядом с `closeExternalClients`/finalize, `ai-chat.service.ts:478-531`): upsert снимка текущим Markdown + `updatedAt`; сидинг на первом ходу. ## Краевые случаи - Страница не открыта → логика снимков не запускается. - Первый ход / нет снимка → только сидинг, заметки нет. - `updatedAt` не изменился → быстрый путь, без рендера и диффа. - Дифф после нормализации пустой (только пробелы/форматирование) → заметки нет. - Большой дифф → капа + подсказка перечитать через `getPage`. - Страница удалена в ходе → снимок не пишем. - Смена/несколько страниц → снимок по `(chat, page)`, свой ряд на страницу. - Гонка (правка пользователя во время хода) → поймаем на следующем ходу. ## Тесты - `page-change.util.spec.ts`: чистая функция диффа (изменение/пусто/нормализация/капа). - `ai-chat.prompt.spec.ts`: рендер блока `<page_changed>` по флагу и его отсутствие без флага. - `ai-chat-page-snapshot.repo.spec.ts`: upsert/find. - Сервисный lifecycle-тест: снимок обновляется на `onFinish`; заметка появляется ровно на один ход и самоочищается. ## Вне scope / на будущее - **B1 (optimistic-lock на запись)** — жёсткая гарантия «старый текст не затрёт свежий»: write-тул отклоняет правку от устаревшей базы (как это делает сам Claude Code — «file modified since read»). Заметка из этого issue — advisory-слой; B1 клеим как hardening, если на практике агент будет игнорировать заметку. Требует версионирования write-тулов, поэтому отложено. - Опциональный провенанс-guard (`last_updated_source`/`last_updated_ai_chat_id`) как быстрый пре-чек. - Возможная связь с #247 (слепки истории по Save/простою + матчинг с агентами).
vvzvlad added the feature label 2026-07-01 18:27:39 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#274