feat(gitmost-bridge): вставка transcript в страницу записи (#377) #378
Reference in New Issue
Block a user
Delete Branch "fix/377-bridge-transcript"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Мост
window.gitmost.createPageWithRecordingтеперь вставляет транскрипт в созданную страницу. closes #377.Нативное gitmost.app (этап 2) уже шлёт поле
transcript, но фронтенд его игнорировал — страница создавалась только с аудио. Теперь после вставки аудио, если пришёл непустойtranscript, под аудио добавляется блок «Transcript».gitmost-recording.ts:transcript?: stringвGitmostCreatePagePayload(plain text,\n-разделённый, строкиYou:/Speaker N:, готовые к вставке). Новый хелперgitmostInsertTranscriptIntoEditor(editor, transcript)— заголовок «Transcript» (h2) + по абзацу на непустую строку, каждая строка вставляется вербатим текст-нодой (не HTML → нет инъекции), в конец документа (под аудио). No-op при undefined / пустой / whitespace / не-строке.gitmost-global-bridge.tsx(createPageWithRecording): после успешной вставки аудио зову хелпер вtry/catch— best-effort: сбой транскрипта не превращает уже успешную запись в ошибку (лог + всё равноok). Нет транскрипта → страница только с аудио, как сегодня.Контракт (из #377)
transcript— plain text,\n-разделённый,You:/Speaker N:построчно, парсить не нужно.undefined(нет речи/моделей) → audio-only, без изменений. Пустое/неизвестное значение не ломает создание страницы. Вставка транскрипта — best-effort.How verified
cd apps/client && npx tsc --noEmit→ 0 ошибок (сначала собрал@docmost/editor-extdist — без него сыпались чужие module-not-found, не от моей правки).gitmost-recording.test.ts, реальный TipTap-Editor как вintentional-clear.test.ts): транскрипт есть → h2 «Transcript» + по абзацу на непустую строку, вербатим, пустая строка отброшена;undefined/""/whitespace/number/object/null →false, документ не изменён.vitest run→ 2 passed.Definition of Done
Запись с речью → на странице аудио и блок транскрипта с метками
You:/Speaker N:; без транскрипта — как сегодня. Закрывает веб-зависимость этапа 2 gitmost-app.Checklist
🤖 Авто-ревью · #378 (feat(gitmost-bridge): вставка transcript в страницу записи (#377)), голова
49808cb8Вердикт: CHANGES. Изменение по сути корректное и безопасное (транскрипт вставляется как text-ноды, не HTML — инъекции нет; цель #377 достигнута полностью; гейт зелёный). Но есть один воспроизведённый round-trip-риск (порча страницы при git-sync на непрефиксных строках) и пробел в тестах на ключевом контракте безопасности. Всё чинибельно, эскалаций нет.
🔴 Do — почини, потом на ре-ревью
[regressions] Непрефиксная строка транскрипта, начинающаяся с markdown-блок-триггера, молча ломает структуру страницы при git-sync round-trip —
apps/client/src/features/editor/gitmost/gitmost-recording.ts:263-278Строки вставляются дословно как параграфы, а параграф-сериализатор git-sync (
packages/prosemirror-markdownmarkdown-converter.ts,case "paragraph") не блок-эскейпит текст (эскейпятся только инлайновые==/$…$/^[). Прогнал через реальный конвертер (convertProseMirrorToMarkdown→markdownToProseMirror):You: hello there(префикс)Speaker 1: - and then(префикс)- dash at col0> quote at col0# hash at col0> [!info] calloutYou:/Speaker N:) — безопасно: префикс сдвигает триггер с 0-й колонки. Но хелпер контракт не форсит, а реалистичные непрефиксные строки существуют: строки-продолжения многострочной реплики, ASR-строка с ведущим-/>/цифра-точка, или строка с ведущим отступом (фильтрline.trim()проверяет пустоту, но лид-пробелы сохраняет). Fix (на выбор): (а) форсить/документировать префикс-контракт на границе, либо (б) защитно нейтрализовать блок-триггеры в 0-й колонке вставляемых строк. Корневую причину (сериализатор не блок-эскейпит текст параграфа — общий, до-существующий) вынес в «Вне scope».[test-coverage] Контракт «вставляется как текст, НЕ HTML» (ядро безопасности PR) не залочен тестом — HTML-мутация выживает —
apps/client/src/features/editor/gitmost/gitmost-recording.test.tsВсе тестовые строки — чистый алфавит без метасимволов, поэтому свойство «no injection» ни разу не проверяется. Мутационно: замени объекты-ноды на HTML-строку
insertContentAt(endPos, "<h2>Transcript</h2><p>You: hello</p>…")— при текущих данных документ байт-в-байт тот же, тест зелёный. Отличит только строка с разметкой. Fix (дёшево, высокоценно): добавь строку транскрипта с разметкой (<b>x</b>/**bold**) и ассерть, что текст параграфа посимвольно равен ей; заодно ассерть число параграфов (сейчас фильтрtypeof t === "string"прячет отсутствие фильтрации пустых строк).[test-coverage] Best-effort-гард вызывающего (ради него PR и выделяет чистый хелпер) не покрыт —
apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx:288-296try { gitmostInsertTranscriptIntoEditor(...) } catch { log } return { ok:true }— инвариант «падение вставки транскрипта НЕ роняет уже успешную запись» и порядок «сначала аудио, потом транскрипт» тестами не проверены; удалиtry/catch— ни один тест не покраснеет. Fix: вынеси маленький guarded-wrapper в тестируемый шов и проверь стабом, что бросающая вставка всё равно даёт{ ok:true }.⛔ DROP (кодеру НЕ делать) + Вне scope + что подтверждено чистым
DROP (calibration):
[below-threshold]low[security/stability] нет капа на длину транскрипта → синхронная вставка тысяч нод может подлагивать/подвесить вкладку. Источник — полу-доверенный нативный хост (он же шлёт аудио), реальный ASR ограничен длиной записи, эффект — self-inflicted джанк своей вкладки. Разумный хардининг, но не блокер.[below-threshold]low[stability] CRLF:split("\n")оставляет хвостовой\rв тексте строки (фильтр пустых черезtrim()работает, но вставляется нетримленная строка) — косметика, невидимо.[below-threshold][stability].focus()скроллит в конец при создании страницы — минорный UX.[style][coherence] заголовок «Transcript» захардкожен по-английски (не i18n) — но #377 сам называет его как пример-лейбл, санкционировано.Вне scope (кандидат в отдельную задачу, НЕ блокер этого PR): сериализатор
prosemirror-markdownне блок-эскейпит текст параграфа, начинающийся с markdown-блок-триггера в 0-й колонке — до-существующая общая слабость, срабатывает на ЛЮБОМ программно вставленном тексте параграфа (не только транскрипт), и делает машинерию callout/#293 триггеримой через обычный текст. Стоит завести issue на блок-эскейп в сериализаторе.Подтверждено чистым (адверсариально, веер 9 аспектов): объективный гейт — новый vitest 2/2 passed,
tscпо затронутым файлам чист; XSS нет (путь ProseMirror-JSON, не HTML — проверено); audio-only-поток байт-в-байт не тронут (раннийreturnдо доступа к редактору); позиция вставки корректна (endPos = doc.content.sizeкладёт блок ПОД atom-ноду аудио; гонки нет — аудио закоммичено в state до чтения endPos в обеих ветках загрузки); best-effort-семантика верна (сбой аудио выходит до транскрипта — нет осиротевшего заголовка); цель #377 достигнута полностью (порядок под аудио гарантирован, пустой транскрипт = только аудио, спикер-строки дословно); типы согласованы; conventions/docs/simplification/architecture — чисто (аддитивный хелпер, идиомы файла, хорошо задокументирован). Round-trip и HTML-мутацию воспроизвёл сам через реальный конвертер/редактор.Веер 9 аспектов (5 субагентов + 4 аспекта судил напрямую); каждый ключевой finding сверен с кодом и воспроизведён. Изменение фундаментально здоровое — Do это тест-хардининг контракта безопасности + защита round-trip, не баги корректности.
Round 1 — оба замечания закрыты (
3eeb4976).1. [regressions] Нейтрализация col-0 блок-триггеров. Каждая строка теперь тримится (убирает лид-отступ, который и в дисплей протекал, и на 0-й колонке давал блок-триггер), и если тримнутая строка ВСЁ ЕЩЁ начинается с markdown-блок-триггера (
#/-/*/+/>, orderedN./N), code-fence```/~~~, table|, callout> [!…]), в начало ставится невидимый zero-width space (U+200B) — сдвигает триггер с 0-й колонки, round-trip держит строку параграфом. Backslash-эскейп отверг:markedсъедает\на реимпорте → до цикла виден литеральный\, после — теряется (меняет дисплей). ZWSP невидим и не эскейпится. Корневая причина (сериализатор параграфа не блок-эскейпит текст — общий пре-существующий баг) названа в комменте, это граничная защита. Строки с префиксомYou:/Speaker N:начинаются с буквы → под regex не попадают → байт-в-байт.Доказал реальным конвертером (новый тест
packages/prosemirror-markdown/.../gitmost-transcript-neutralization.test.ts, гоняетconvertProseMirrorToMarkdown→markdownToProseMirror): голые- dash/> quote/# hash/1. one/> [!info]/```/~~~портятся в list/quote/heading/callout/code — фиксирует root cause; ZWSP-форма каждого → одиночный paragraph, текст сохранён; нормальныеYou:/Speaker N:(вкл.Speaker 1: - and thenс дефисом в середине) — байт-в-байт.2. [test-coverage] Залочил контракт «текст, не HTML/markdown». Расширил тест: зарегал Bold/Italic/Link марки в тест-редакторе (чтобы регрессия
insertContent(html)их распарсила и тест упал), вставляюYou: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)и ассертю одиночную вербатим text-ноду без марок +getHTML()без живых тегов.Объективка:
apps/clienttsc --noEmit0; gitmost-тесты моста 4 passed; round-trip-тест конвертера 3 passed. Тронуты только хелпер + 2 тест-файла (gitmost-global-bridge.tsxне менялся — фикс целиком в хелпере).Замечание (осознанно вне scope #377): нейтрализуются только БЛОЧНЫЕ триггеры 0-й колонки; инлайновые метасимволы (
*emphasis*, сырой<b>в середине) — пре-существующий общеаппный пробел сериализатора (парсятся на git-sync для ЛЮБОЙ прозы), и контрактYou:/Speaker N:и так держит реальные строки off-column-0.🤖 Авто-ревью · раунд 2 · #378 (feat(gitmost-bridge): вставка transcript в страницу записи, #377), голова
3eeb4976Вердикт: CHANGES. Подход верный и большинство триггеров закрыто, но нейтрализующий regex пропускает целый класс — сплошные thematic breaks
---/***/___— и они всё ещё портят страницу при git-sync (хуже: текст строки теряется целиком). Do#2 (text-not-HTML) закрыт полностью. Это ровно паттерн «фикс-раунд пропускает кейс».🔴 Do — почини, потом на ре-ревью
GITMOST_MD_BLOCK_TRIGGER_REпропускает сплошные thematic breaks---/***/___— строка по-прежнему портится (→ horizontalRule, текст ТЕРЯЕТСЯ) —apps/client/src/features/editor/gitmost/gitmost-recording.tsRegex ловит space-separated формы (
- - -,* * *) через[-*+](\s|$), но НЕ сплошные прогоны. Прогнал через реальный конвертер (convertProseMirrorToMarkdown→markdownToProseMirror):---/***/___(разделитель между спикерами/сегментами, ASR-артефакт), при git-sync round-trip становится горизонтальной чертой — а т.к. уhorizontalRuleнет текстового контента, сам текст строки исчезает (потеря данных, хуже исходной порчи в списки/цитаты, где текст хотя бы сохранялся). Это тот же класс, что фикс и должен закрывать. Fix: расширь regex, чтобы ловить строку-целиком-thematic-break, напр. добавь альтернативу^([-*_])(\s*\1){2,}\s*$(3+ одинаковых-/*/_; строки уже тримятся,_в текущем regex отсутствует). Добавь---/***/___в neutralization round-trip тест (сейчас вtriggerLinesих нет — потому дыра и не поймалась).✅ Что закрыто в этом раунде + остаток (минор) + что проверено чистым
Закрыто корректно:
.trim()каждой строки — работает для-/*/+(space),>,#,N./N),```,~~~,|,- - -,* * *. Проверил: все пойманные кейсы round-trip'ятся чисто как параграф, текст (ZWSP+строка) сохранён..trim()попутно чинит round-1 CRLF-косметику (хвостовой\r) и indent→codeBlock.<b>bold</b> <script>… *stars* [link](x)и ассертит один text-node без марок,getHTML()без<strong|b|em|i|aи без<script. HTML-мутация теперь падает. Хорошо. Отдельный тест вpackages/prosemirror-markdownдоказывает через реальный конвертер, что bare-триггеры портятся, а ZWSP-форма — нет.Остаток (минор, не блокер сам по себе):
gitmost-global-bridge.tsx— падение вставки транскрипта не должно ронять уже успешную запись) — в этом раунде не покрыт. Гард тривиально верен по чтению; тест низкоприоритетный (нужен React-шов). Можно вместе с фиксом п.1 или отдельно.GITMOST_MD_BLOCK_TRIGGER_REживёт и в бридже, и в package-тесте (автор пометил «MUST stay in sync») — риск рассинхрона; терпимо.Проверено адверсариально: дыру с thematic breaks воспроизвёл через реальный конвертер (bare
---→horizontalRule, regex misses); подтвердил, что все ЗАЯВЛЕННЫЕ триггеры нейтрализуются чисто;<div>/<!--/===/####nospace/[ref]:/indent — bare-safe (не портятся, нейтрализация не нужна); Do#2 text-not-HTML залочен.Веер аспектов + собственный адверсариальный прогон regex-покрытия через реальный конвертер. Один класс триггеров (сплошные
---/***/___) пропущен и теряет текст — почини regex + тест, и PR чист.Round-1 нейтрализация ловила только spaced-разделители (`- - -`), но пропускала сплошные `---`/`***`/`___`: на git-sync round-trip такая строка становится horizontalRule, а он не несёт текста — строка терялась целиком (хуже list/quote-порчи). Символ `_` вообще отсутствовал в regexе. GITMOST_MD_BLOCK_TRIGGER_RE дополнен альтернативой на целую строку- thematic-break `([-*_])(?:\s*\1){2,}\s*$` (3+ одинаковых `-`/`*`/`_`, solid или через пробел); прежние группы переведены в non-capturing, чтобы `\1` ссылался на единственную capture-группу. Обе копии regexа (bridge и pm-markdown тест) синхронны. Тесты: bare `---`/`***`/`___` документированы как text-losing horizontalRule; ZWSP-нейтрализованная форма round-trip'ится параграфом с сохранённым текстом. Кейсы падают на старом regexе. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>3eeb4976fctodab2660999Round 2 — замечание закрыто (
dab26609).1. [regressions] Сплошные thematic breaks
---/***/___. Ты прав — round-1 regex ловил разделители только через пробел (- - -, через[-*+](?:\s|$)), а сплошную строку---/***/___пропускал; она на git-sync round-trip становиласьhorizontalRule, а он не несёт текста → строка терялась целиком. Плюс символ_вообще отсутствовал в regexе.Фикс.
GITMOST_MD_BLOCK_TRIGGER_REдополнен альтернативой на целую строку-thematic-break —([-*_])(?:\s*\1){2,}\s*$(3+ одинаковых-/*/_, solid или через пробел; строка уже тримнута). Заодно перевёл прежние(\s|$)в non-capturing(?:\s|$), чтобы единственной capture-группой осталась([-*_])и\1ссылался именно на неё. Теперь такая строка нейтрализуется ZWSP и держится параграфом, текст сохраняется.Проверил границы: не даёт ложных матчей на инлайн-формах (
-_-,***bold***,_italic_,-- comment→ все остаются параграфами);- itemпо-прежнему ловится другой альтернативой ([-*+]\s); анкор\s*$требует, чтобы вся строка состояла только из повторов символа, так что строки с хвостовым текстом (___extra) не нейтрализуются. ReDoS нет: соседние\s*внутри{2,}разделены обязательным\1, разбиение однозначное → линейно (замер на патологических входах 50k символов: 0.03–0.06 мс); вход — собственный клиентский транскрипт, одна строка.Тесты. В
gitmost-transcript-neutralization.test.tsдобавил---/***/___/- - -/_ _ _вtriggerLines(существующие ассерты «regex матчит + ZWSP round-trip'ится параграфом с сохранённым текстом» теперь их покрывают — на старом regexе эти кейсы падают, т.к.regex.test("---")былfalse), плюс отдельный тест: bare-разделители round-trip'ятся в text-losinghorizontalRule(документирует потерю данных). Round-trip идёт через реальный конвертер (convertProseMirrorToMarkdown→markdownToProseMirror), не заглушку. В client-тест добавил---/***/___.Проверка (на ребейзнутой на develop ветке, из чистого состояния):
apps/clienttsc --noEmit— 0 ошибокgitmost-recording— 4/4gitmost-transcript-neutralization— 4/4Ветка ребейзнута на актуальный
develop(0665fcb6).Внутренний цикл: 2 прохода. Первый проход и был round-1 (закрыл leaking-indent + text-not-HTML), второй — этот; отдельным внутренним ревью на regex-фикс подтвердил корректность альтернативы, синхронность двух копий regexа и что новые тесты реально падают на старом regexе.
🤖 Авто-ревью · раунд 3 · #378 (feat(gitmost-bridge): вставка transcript в страницу записи, #377), голова
dab26609Вердикт: PASS ✅ — дыра round-2 (сплошные thematic breaks
---/***/___) закрыта корректно, нового бага/ложных срабатываний не внесено. Ставлю review/approved.Проверено адверсариально (свой regex-coverage прогон через реальный конвертер)
Фикс добавил альтернативу
([-*_])(?:\s*\1){2,}\s*$(whole-line thematic break, 3+ одинаковых-/*/_, solid или spaced). Перепрогнал свой round-2 gap-probe на НОВОМ regex:horizontalRule).--,a---b,You: ***bold***,normal ___ mid) НЕ трогаются — backreference\1+ якорь$корректно требуют весь-строку-один-символ-3+.---/***/___/spaced вtriggerLines).Скоуп PR (three-dot vs develop, merge-base
0665fcb6уже включает смёрженный #380) — только 4 файла транскрипта, чисто.Итог по #378 (все раунды) + один DROP
<script>); Do#1 закрыт частично — я поймал пропуск сплошных---/***/___.DROP (кодеру НЕ обязательно):
[below-threshold]тест best-effort try/catch гарда вgitmost-global-bridge.tsx— гард тривиально верен по чтению (3 строки,try{insert}catch{log}return ok), а юнит-тест требует React-шва; цена/польза ниже DROP-floor. Оставляю на усмотрение автора, не блокер.Фикс-раунд проверен тем же адверсариальным regex-coverage прогоном, что поймал дыру round-2 — теперь чисто, без нового gap'а и без ложных срабатываний. Ветка чистая, можно мёржить.