feat(mcp): детерминированная нормализация текста сносок + слияние по нормализованному тексту — гарантированно склеивать сноски-«форки» (кавы… #419

Open
opened 2026-07-08 00:03:38 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

Сноски с одинаковым смыслом, но разным начертанием символов (типографские кавычки «…»/"…" vs ASCII "…", em/en-dash vs -, NBSP vs обычный пробел, разное число пробелов) не распознаются как одинаковые и «форкаются» — на странице появляется два определения вместо одного. Наблюдается в проде.

Почему текущий дедуп это не ловит:

  • Дедуп по содержимомуfootnoteContentKey (packages/mcp/src/lib/footnote-authoring.ts:31-50), используется в insertInlineFootnote (transforms.ts:708-728) и commentsToFootnotes. Ключ нормализует только пробелы (.replace(/[ \t\r\n]+/g, " ").trim()) и приклеивает сигнатуру inline-марок. Кавычки, тире и NBSP он не трогает → форки по этим символам дают разные ключи и не сливаются.
  • Дедуп канонизатораcanonicalizeFootnotes (footnote-canonicalize.ts) работает по attrs.id, а не по тексту. Две сноски-форка имеют разные id → он их не объединяет.

Решение (согласовано)

Детерминированная серверная нормализация самого текста определений сносок (не только ключа сравнения) на путях записи MCP, с последующим слиянием определений, у которых нормализованный текст совпал.

Согласованные параметры:

  • Где: детерминированный код в MCP (не инструкция LLM-агенту — та не даёт гарантии склейки).
  • Объём нормализации: полный набор — кавычки → ASCII "/', em/en-dash → -, NBSP → обычный пробел, схлопывание пробелов, trim.
  • Компромисс принят осознанно: точные символы (кавычки/тире) в выжившей сноске теряются ради гарантированной склейки.

Точки интеграции

Новый чистый модуль, напр. packages/mcp/src/lib/footnote-normalize-merge.ts, экспорт normalizeAndMergeFootnotes(doc) — pure, deep-clone входа, идемпотентен.

Переиспользовать карты символов из normalizeForMatch (comment-anchor.ts:22-25DOUBLE_QUOTES/SINGLE_QUOTES/DASHES; маппинг :82-88). Сам normalizeForMatch не подходит напрямую (возвращает {norm, map} для матчинга якорей, ничего не мутирует) — вынести карты символов в общий хелпер и переиспользовать в обоих местах, либо продублировать константы.

НЕ трогать canonicalizeFootnotes — это pure-mirror редакторского канонизатора, привязанный к общему golden-корпусу (packages/editor-ext/src/lib/footnote/footnote-corpus.ts); любое изменение его поведения ломает twin-тест. Новый пасс — строго отдельная функция.

Вызвать новый пасс ПЕРЕД canonicalizeFootnotes на тех же путях записи, где канонизатор уже вызывается (enforcement rule #228 в шапке footnote-canonicalize.ts):

Путь Call-site
markdown-импорт (markdownToProseMirrorCanonical) packages/mcp/src/lib/collaboration.ts:85
updatePageJson (raw JSON write) packages/mcp/src/client.ts:1697
copyPageContent packages/mcp/src/client.ts:1961
docmost_transform packages/mcp/src/client.ts:3670
insertInlineFootnote packages/mcp/src/lib/transforms.ts:770

Порядок: normalizeAndMergeFootnotes(doc)canonicalizeFootnotes(doc). Канонизатор после перевешивания ссылок добьёт порядок / единственный хвостовой список / удаление сирот.

Механика пасса

  1. Рекурсивно обойти все footnoteDefinition (могут быть вложены в callout/blockquote/list — как в collectDefinitions), нормализовать текст их text-нод in place (замена кавычек/тире/NBSP по картам, схлопывание пробелов, trim). marks на нодах сохранять — начертание выжившей сноски не теряем.
  2. Для каждого определения посчитать ключ слияния = нормализованный текст + сигнатура inline-марок (как в footnoteContentKey), чтобы визуально разные заметки (bold vs plain при одинаковом тексте) НЕ сливались. Консервативно: сливаем только при точном совпадении ключа.
  3. Построить mergeKey → canonicalId (первое определение в порядке документа выигрывает) и производный defId → canonicalId для дублей.
  4. Обойти все footnoteReference, у дублей переписать attrs.id на canonicalId.
  5. Дубли-определения остаются со своими исходными id, но уже без ссылок → канонизатор снесёт их как сирот.

Edge cases

  • Пустые сноски (нормализованный текст "") — НЕ сливать друг с другом (иначе схлопнутся все пустые в одну). Ключ пустой → пропускать, как уже делает insertInlineFootnote при key === "".
  • Один id у нескольких ссылок — не ломать (мульти-референс сноски — норма).
  • Многопараграфные / сложные определения — нормализуем текст всех text-нод, но структуру не схлопываем.
  • Scope строго — только внутри footnoteDefinition. Текст тела документа (вне сносок) НЕ нормализуем: кавычки в обычных абзацах остаются как есть.
  • Идемпотентность: повторный прогон = no-op (уже нормализовано → тот же результат, без лишнего git-sync churn).

Открытый вопрос для реализации

Нормализовать текст всех определений безусловно (единообразно, но переписывает кавычки даже в одиночных, ни с чем не сливаемых сносках → больше git-churn) или только в группах, где реально произошло слияние (меньше потерь, но одиночные форки-по-начертанию не выравниваются). Базовый выбор по формулировке задачи («нормализовывать текст», «похуй на кавычки», «гарантированно») — безусловно все; зафиксировать окончательно на ревью.

DoD / тесты

  • Юнит: «…» vs "…" → одна сноска; то же для тире, NBSP, лишних пробелов.
  • Одинаковый текст, разное начертание (bold vs plain) → НЕ сливаются.
  • Ссылки перевешиваются на canonicalId; дубли удаляются канонизатором (проверить итоговый один хвостовой список и нумерацию).
  • Идемпотентность (двойной прогон = тот же документ).
  • Вложенные определения (в callout).
  • Пустые сноски не схлопываются друг в друга.
  • Текст тела документа (вне сносок) не изменён.
  • Интеграция: пасс отрабатывает на каждом из 5 call-site'ов.

Вне scope

  • Нормализация в editor-ext (сноски, созданные в живом редакторе TipTap) — отдельный follow-up при необходимости; здесь только серверные MCP write-paths (исходный «серверный фикс»).
  • Нормализация кавычек в теле документа (только сноски).

Связанные

  • #228 — enforcement rule для canonicalizeFootnotes на write-paths (те же call-site'ы).
  • #410 / #418insertFootnote для in-app агента.
  • #414 — рефактор footnote-* хелперов (учесть, чтобы новый модуль не попал под тот же форк).
## Проблема Сноски с одинаковым смыслом, но разным **начертанием символов** (типографские кавычки `«…»`/`"…"` vs ASCII `"…"`, em/en-dash vs `-`, NBSP vs обычный пробел, разное число пробелов) не распознаются как одинаковые и **«форкаются»** — на странице появляется два определения вместо одного. Наблюдается в проде. Почему текущий дедуп это не ловит: - **Дедуп по содержимому** — `footnoteContentKey` (`packages/mcp/src/lib/footnote-authoring.ts:31-50`), используется в `insertInlineFootnote` (`transforms.ts:708-728`) и `commentsToFootnotes`. Ключ нормализует **только пробелы** (`.replace(/[ \t\r\n]+/g, " ").trim()`) и приклеивает сигнатуру inline-марок. Кавычки, тире и NBSP он **не трогает** → форки по этим символам дают разные ключи и не сливаются. - **Дедуп канонизатора** — `canonicalizeFootnotes` (`footnote-canonicalize.ts`) работает по `attrs.id`, а не по тексту. Две сноски-форка имеют **разные id** → он их не объединяет. ## Решение (согласовано) Детерминированная **серверная** нормализация самого **текста определений сносок** (не только ключа сравнения) на путях записи MCP, с последующим **слиянием** определений, у которых нормализованный текст совпал. Согласованные параметры: - **Где:** детерминированный код в MCP (не инструкция LLM-агенту — та не даёт гарантии склейки). - **Объём нормализации:** полный набор — кавычки → ASCII `"`/`'`, em/en-dash → `-`, NBSP → обычный пробел, схлопывание пробелов, trim. - **Компромисс принят осознанно:** точные символы (кавычки/тире) в выжившей сноске теряются ради **гарантированной** склейки. ## Точки интеграции **Новый чистый модуль**, напр. `packages/mcp/src/lib/footnote-normalize-merge.ts`, экспорт `normalizeAndMergeFootnotes(doc)` — pure, deep-clone входа, идемпотентен. **Переиспользовать** карты символов из `normalizeForMatch` (`comment-anchor.ts:22-25` — `DOUBLE_QUOTES`/`SINGLE_QUOTES`/`DASHES`; маппинг `:82-88`). Сам `normalizeForMatch` не подходит напрямую (возвращает `{norm, map}` для матчинга якорей, ничего не мутирует) — вынести карты символов в общий хелпер и переиспользовать в обоих местах, либо продублировать константы. **НЕ трогать `canonicalizeFootnotes`** — это pure-mirror редакторского канонизатора, привязанный к общему golden-корпусу (`packages/editor-ext/src/lib/footnote/footnote-corpus.ts`); любое изменение его поведения ломает twin-тест. Новый пасс — строго отдельная функция. **Вызвать новый пасс ПЕРЕД `canonicalizeFootnotes`** на тех же путях записи, где канонизатор уже вызывается (enforcement rule #228 в шапке `footnote-canonicalize.ts`): | Путь | Call-site | |---|---| | markdown-импорт (`markdownToProseMirrorCanonical`) | `packages/mcp/src/lib/collaboration.ts:85` | | `updatePageJson` (raw JSON write) | `packages/mcp/src/client.ts:1697` | | `copyPageContent` | `packages/mcp/src/client.ts:1961` | | `docmost_transform` | `packages/mcp/src/client.ts:3670` | | `insertInlineFootnote` | `packages/mcp/src/lib/transforms.ts:770` | Порядок: `normalizeAndMergeFootnotes(doc)` → `canonicalizeFootnotes(doc)`. Канонизатор после перевешивания ссылок добьёт порядок / единственный хвостовой список / удаление сирот. ## Механика пасса 1. Рекурсивно обойти все `footnoteDefinition` (могут быть вложены в callout/blockquote/list — как в `collectDefinitions`), нормализовать текст их `text`-нод **in place** (замена кавычек/тире/NBSP по картам, схлопывание пробелов, trim). **`marks` на нодах сохранять** — начертание выжившей сноски не теряем. 2. Для каждого определения посчитать **ключ слияния** = нормализованный текст **+ сигнатура inline-марок** (как в `footnoteContentKey`), чтобы визуально разные заметки (bold vs plain при одинаковом тексте) НЕ сливались. Консервативно: сливаем только при точном совпадении ключа. 3. Построить `mergeKey → canonicalId` (первое определение в порядке документа выигрывает) и производный `defId → canonicalId` для дублей. 4. Обойти все `footnoteReference`, у дублей переписать `attrs.id` на `canonicalId`. 5. Дубли-определения остаются со своими исходными id, но уже без ссылок → канонизатор снесёт их как сирот. ## Edge cases - **Пустые сноски** (нормализованный текст `""`) — НЕ сливать друг с другом (иначе схлопнутся все пустые в одну). Ключ пустой → пропускать, как уже делает `insertInlineFootnote` при `key === ""`. - **Один id у нескольких ссылок** — не ломать (мульти-референс сноски — норма). - **Многопараграфные / сложные определения** — нормализуем текст всех `text`-нод, но структуру не схлопываем. - **Scope строго — только внутри `footnoteDefinition`.** Текст тела документа (вне сносок) НЕ нормализуем: кавычки в обычных абзацах остаются как есть. - **Идемпотентность:** повторный прогон = no-op (уже нормализовано → тот же результат, без лишнего git-sync churn). ## Открытый вопрос для реализации Нормализовать текст **всех** определений безусловно (единообразно, но переписывает кавычки даже в одиночных, ни с чем не сливаемых сносках → больше git-churn) **или** только в группах, где реально произошло слияние (меньше потерь, но одиночные форки-по-начертанию не выравниваются). Базовый выбор по формулировке задачи («нормализовывать текст», «похуй на кавычки», «гарантированно») — **безусловно все**; зафиксировать окончательно на ревью. ## DoD / тесты - Юнит: `«…»` vs `"…"` → одна сноска; то же для тире, NBSP, лишних пробелов. - Одинаковый текст, разное начертание (bold vs plain) → **НЕ** сливаются. - Ссылки перевешиваются на `canonicalId`; дубли удаляются канонизатором (проверить итоговый один хвостовой список и нумерацию). - Идемпотентность (двойной прогон = тот же документ). - Вложенные определения (в callout). - Пустые сноски не схлопываются друг в друга. - Текст тела документа (вне сносок) не изменён. - Интеграция: пасс отрабатывает на каждом из 5 call-site'ов. ## Вне scope - Нормализация в editor-ext (сноски, созданные в живом редакторе TipTap) — отдельный follow-up при необходимости; здесь только серверные MCP write-paths (исходный «серверный фикс»). - Нормализация кавычек в теле документа (только сноски). ## Связанные - #228 — enforcement rule для `canonicalizeFootnotes` на write-paths (те же call-site'ы). - #410 / #418 — `insertFootnote` для in-app агента. - #414 — рефактор footnote-* хелперов (учесть, чтобы новый модуль не попал под тот же форк).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#419