Andrey Epifancev 4848dff206 OpenSpec: change wiki-package-d — пакет D модуля wiki (#135)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:31:55 +04:00

127 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Context
Пакет D сверки follow-ups 25.10.2026 (sdlc-hub#135). Маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 сверены с кодом
27.10.2026 и воспроизведены на `convert.to_markdown`:
| Маркер | Вход | Сейчас |
|---|---|---|
| T6.1 | `<a href="…/f.xlsx"><span><img src="…/f.xlsx.meta/thumbnail.jpg"></span></a> текст` | `текст` — ссылки на вложение нет, строка вложения есть |
| T6.2 | `<a href="project/Document/DOC-1"><img src="…/x.png"></a>` | `[\[📎 x.png](attachment:@0)](page:DOC-1)` — ссылка в ссылке |
| T6.3 | два `attachments` с одним `ref` | побеждает последний (`dict` по `ref`) |
| T6.4 | `<a href="http://ex.com/a b(c).html">док</a>` | `[док](http://ex.com/a b(c).html)` — не ссылка |
| T6.5 | `<a href="project/Document/DOC-1">путь C:\</a>` | `[путь C:\](page:DOC-1)` — `\]` экранирует скобку |
T8.2: `request_fetch` отвечает `ready` по полю `state`, а `GET …/content` при пропавшем файле — 409 «запросите
скачивание»: тупик. T8.3: `POST /wiki/sync` проверяет `active_sync`, а `POST /batches` и `POST /commands/{id}/retry`
проходят только `commands.check_batch`, где этой проверки нет. T9.3: `mcp.py` склеивает шапку `page`, строки `tree`,
`search` и кандидатов из значений Eva без нормализации переводов строк.
Владелец 27.10.2026 согласился с предложенным поведением по всем восьми маркерам и добавил картинки в тексте страницы
«Вики»: показ только после нажатия, уже скачанные — сразу.
## Decisions
1. **T6.1 — предок, а не родитель.** `convert_img` ищет ближайшего предка `<a>` (`el.find_parent("a")`), а не только
`el.parent`. Если `href` этого `<a>` — вложение и картинка — то же вложение (совпадает `url` записей) или вообще не
вложение, картинка не выводится: ссылку выведет `<a>`. Картинка — другое вложение (миниатюра файла Y внутри ссылки
на файл X) выводит свою ссылку, а `convert_a` для вложения X сохраняет маркеры вложений из `text` перед своей
ссылкой (то же правило, что в решении 2): Y не теряется.
2. **T6.2 — вложение перед ссылкой на страницу.** В `convert_a` для ссылки на документ (`code` найден): если в `text`
(уже сконвертированное содержимое) есть маркеры вложений (`_MARK`), они выносятся перед ссылкой: результат —
`<ссылки вложений> [<остаток текста или код>](page:<код>)`. Остаток — `text` без фрагментов `[📎 …<маркер>`;
пустой после `strip()` — код. Через `_label` как сейчас. Внешняя ссылка (`super().convert_a`) с картинкой-вложением
внутри — то же правило: вложение перед ссылкой, текст ссылки — остаток, пустой — адрес. Ссылка на вложение внутри
ссылки на вложение уже покрыта решением 1.
3. **T6.3 — первая запись.** `_by_ref` и `_links` собираются с `setdefault`: первая запись по `ref` побеждает (порядок
агента — порядок документа).
4. **T6.4 — кодирование адреса.** Для внешних ссылок и картинок (ветка `super().convert_a`/`convert_img`) адрес
проходит `_href(url)`: пробел → `%20`, `(` → `%28`, `)` → `%29`; прочие символы не трогаются (уже закодированное не
кодируется повторно). Ссылки `page:`/`attachment:` не затрагиваются — в них таких символов нет.
5. **T6.5 — хвостовая черта.** `_label` после текущих замен удваивает нечётную серию `\` в конце строки (одна `\`
в конце → `\\`). То же в тексте внешней ссылки, если markdownify не экранировал её сам; проверять тестом обе ветки.
6. **T8.2 — проверка файла в `request_fetch`.** `request_fetch(conn, attachment_id, author, files_dir)`: при `state =
ready` и `files.size(files_dir, row["file"]) is None` вложение идёт по ветке «поставить скачивание» (state
`fetching`, новый пакет). Маршрут `h_attachment_fetch` передаёт `hub.files_dir`; MCP `attachment` ходит через этот
же маршрут и поведение получает без правок. `GET /wiki/pages/{code}` и `GET /wiki/attachments/{id}` по-прежнему
отдают `state` из базы (проверка файла на каждом чтении — лишние обращения к диску).
7. **T8.3 — проверка в `check_batch`.** Для каждой `wiki_sync` в пакете: если у проекта есть команда `wiki_sync` в
`queued`/`claimed` с тем же `params.root`, — `CommandError` «у проекта <метка> уже идёт синхронизация корня <root>
(пакет #N)» → 422 `bad_batch`. Тот же `root`, а не любой: `projects.set_root` ставит синхронизацию нового корня,
пока команда старого может быть `claimed` (её отчёт будет отброшен по «корень сменился»). `POST /wiki/sync`
по-прежнему проверяет `active_sync` раньше и отвечает 409; фоновая работа — тоже раньше (`tick.py`).
Возврат корня (A → B → A, пока `wiki_sync {root: A}` ещё `claimed`): `projects.on_updated` отменяет только
`queued`, и новая синхронизация A упёрлась бы в проверку — PATCH получил бы 422. Поэтому `commands.enqueue_sync`
перед постановкой ищет активную `wiki_sync` проекта с тем же `root` и, если она есть, возвращает её пакет без
нового: её отчёт не будет отброшен (корень снова совпадает). Ответ — в форме `create_batch`: `{id,
estimate_seconds, count}` из строки пакета и числа его команд; для вызывающих (`on_created`, `on_updated` → ответ
PATCH, `POST /wiki/sync`, `tick`) это «пакет синхронизации проекта», новый он или идущий — не различается. Проверка
в `check_batch` остаётся для `POST /batches` и повтора команды; `enqueue_sync` с поиском и `check_batch`
реализуются одной задачей, иначе `enqueue_sync` между шагами упирается в новую проверку.
8. **T9.3 — одна функция строки.** В `modules/wiki/mcp.py` — `_line(v)`: `core.value(v)`, затем `re.sub(r"\s*[\r\n]+\s*",
" ", …)`. Применяется ко всем значениям из Eva внутри строк ответа: `tree` (название), `page` (название, путь, имена
вложений, состояние), кандидаты `_candidates` (название и сам `title` запроса), `search` (название, путь, фрагмент),
`attachment` (имя, код страницы), `_summary`/`status` (название корня, ошибка синхронизации). Тело страницы — как
есть.
9. **Признак картинки — от хаба по адресу.** Имя вложения для этого не годится: проверка живого AWPCA 27.10.2026
показала имена без расширения («Схема расчёта суммы элиминации по тройке» — файл `vgo-matching-1.png`; 14 вложений
с именем `attachment`) — eva-agent берёт `alt`/`title`/текст ссылки раньше имени файла. Хаб добавляет в
`attachments` ответа `GET /wiki/pages/{code}` поле `image`: последний сегмент пути `url` (без запроса,
`unquote`) оканчивается на `.png .jpg .jpeg .gif .webp .bmp` без учёта регистра. Функция — в
`modules/wiki/attachments.py`, схема базы не меняется. SVG исключён: файл из Eva недоверенный, а пользы в вики от
него мало.
10. **Картинки — устройство во фронте.**
- `useWikiDownload.start(id, mode)` получает режим: `"save"` (как сейчас) или `"show"`. В режиме `show` по `ready`
содержимое (`api.blob`) не сохраняется, а передаётся в хранилище картинок страницы.
- Хранилище картинок страницы (новый хук рядом, например `useWikiImages(project, code, attachments)`): `id →
objectURL`. При загрузке страницы для каждого вложения-картинки в `ready` один раз запрашивает `…/content` и
кладёт objectURL; ошибка — молча, остаётся ссылка. При смене страницы/проекта и размонтировании — `revokeObjectURL`
всех адресов и отмена поздних ответов (поколение, как в `useWikiDownload`).
- Blob заворачивается с типом по расширению (`image/png` и т.д.): хаб отдаёт содержимое как
`application/octet-stream`.
- Один хук на страницу: хранилище картинок живёт внутри `useWikiDownload` (или в хуке, который его оборачивает),
режим `show` кладёт objectURL туда же. Ссылка на объект, от которого зависит `components` в `PageView`, остаётся
стабильной (как сейчас — см. комментарий в `download.ts`): карту картинок `AttachmentLink` читает через
`status(id)`-подобный геттер, меняющийся вместе с картой, как сейчас `status`, а не через пересоздание
`components`. Перемонтирование вложений при появлении картинки не должно сбрасывать `busy`/`error` других.
- Источник картинки — либо автозапрос `content` при загрузке страницы для `ready`-картинок, либо результат
`start(id, "show")`. Автозапрос — один раз на вложение за жизнь страницы; его неудача (409, сеть) оставляет
ссылку в режиме `show` без текста ошибки. Нажатие на неё — `start(id, "show")`: после T8.2 хаб при пропавшем файле
скачивает заново, по `ready` картинка кладётся в хранилище из ответа `start`. Вложение в `failed` — та же ссылка
с текстом ошибки, нажатие — повтор `start(id, "show")`.
- `AttachmentLink`: картинка с objectURL — `<span className="wiki-att-img"><img src alt={name}/></span>` и под ней
прежняя кнопка-ссылка в режиме `save`; картинка без objectURL — кнопка в режиме `show`; не картинка — как сейчас.
- CSS: `.wiki-att-img img { max-width: 100%; height: auto; display: block }`, токены AURA.
- Правило «внешние картинки не загружаются» (`img` в `components`) не меняется: картинки — только вложения хаба.
## Risks / Trade-offs
- **Картинки в памяти.** objectURL держат blob до смены страницы; предел файла — 10 МБ, на странице AWPCA — единицы
картинок. Приемлемо.
- **Состояние вложения после скачивания.** Ответ `GET /wiki/pages/{code}` не перечитывается после скачивания:
картинка показывается из хранилища страницы, при повторном открытии — по `ready` из нового ответа.
- **Корень сменился во время повтора.** Повтор команды старого корня при идущей синхронизации нового проходит (корни
разные); его `done` будет отброшен по «корень сменился». Лишний запрос в Eva, данных не портит.
## Decisions made without discussion
- 2026-10-27 — T6.2: вложение выводится перед ссылкой на страницу, пустой текст ссылки — код страницы. Обоснование:
Markdown не допускает ссылку в ссылке; порядок совпадает с порядком в HTML. Цена ошибки — вид одной строки текста.
- 2026-10-27 — T6.3: при повторе `ref` побеждает первая запись. Обоснование: порядок агента — порядок документа,
первое появление определяет имя. Цена ошибки — другое имя вложения в редком случае.
- 2026-10-27 — T6.4: кодируются только пробел и круглые скобки. Обоснование: только они ломают разбор
`[текст](адрес)`; прочее браузер разбирает сам. Цена ошибки — редкая внешняя ссылка не кликается.
- 2026-10-27 — T8.2: проверка файла — только в `request_fetch`, не при чтении состояния. Обоснование: тупик был в
повторном запросе, не в показе. Цена ошибки — до нажатия вложение выглядит скачанным.
- 2026-10-27 — T8.3: отказ только при активной синхронизации того же корня. Обоснование: смена корня ставит
синхронизацию нового корня при выданной старой (спека «Синхронизация вики»). Цена ошибки — один лишний запрос
дерева в Eva.
- 2026-10-27 — T9.3: все значения Eva в строках ответа MCP — через одну функцию `_line`, тело страницы как есть.
Цена ошибки — длинное значение в одну строку.
- 2026-10-27 — картинки: признак `image` считает хаб по имени файла в адресе Eva (не по имени вложения), SVG не
показывается; под картинкой остаётся ссылка сохранения файла. Обоснование: на живом AWPCA имена картинок бывают
без расширения; SVG — недоверенный активный формат. Цена ошибки — картинка с необычным расширением остаётся ссылкой.
- 2026-10-27 — T8.3: при возврате корня `enqueue_sync` отдаёт идущий пакет того же корня в форме `create_batch`, не
различая «новый/идущий». Цена ошибки — ответ PATCH называет уже идущий пакет.
- 2026-10-27 — T6.1: картинка-другое-вложение внутри ссылки на вложение выводится своей ссылкой перед ссылкой
внешнего вложения. Цена ошибки — порядок двух ссылок в строке.