tasks: спроектирован контракт приложения и переставлена голова очереди

- json-api-for-spa: адреса приложения уехали в своё пространство /app/,
  приём стал POST /app/audiorecords, опрос /api/status/:id убран, заведены
  список, карточка, текст, /app/me и /app/config; имя файла отправителя
  легло своей колонкой рядом с заголовком
- три действия над записью — правка заголовка, возврат в работу и журнал
  событий — собраны задачей audiorecord-actions
- голова очереди: контракт, каркас, экран загрузки, список, действия
- в прежних задачах поправлены адреса, рубежи конвейера и остатки Telegram
This commit is contained in:
av
2026-08-15 09:06:11 +03:00
parent d88e56efcb
commit 79ff12548f
16 changed files with 351 additions and 115 deletions
+5 -4
View File
@@ -43,6 +43,11 @@
## Очередь
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
- [✨ Дать владельцу править запись, возвращать её в работу и видеть её путь](items/audiorecord-actions.md) — С записью нельзя сделать ничего: заголовок ставит одна языковая модель, остановленную возвращает в работу только владелец сервиса в панели, а журнал событий пишется и не читается никем, кроме него же
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
- [✨ Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
@@ -52,7 +57,6 @@
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
- [🧹 Свести пять расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
@@ -63,9 +67,6 @@
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
+3
View File
@@ -22,3 +22,6 @@
- 2026-08-13 `gate-steps-subject-guard` — 🔬 Шаги гейта, у которых правило может потерять предмет. Причина: Владелец отменил 2026-08-13: разведка того же класса — проверка над проверками. Ведут ли себя шаги docs, tasks и openspec зелёными без предмета, остаётся неизвестным. Была секция: Очередь.
- 2026-08-13 `review-config-from-go-upgrade` — 🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade. Причина: Владелец отменил 2026-08-13: настройка конвейера ревью даёт много механики и мало пользы. Разделы «Типовые узлы» и «Триггеры метки» в docs/review.md остаются как есть. Была секция: Очередь.
- 2026-08-13 `rollback-does-not-undo-schema-step` — 🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы. Причина: Владелец отменил 2026-08-13: задача целиком документационная — одна строка в «Необратимое» о том, что откат бинаря не откатывает шаг схемы. Факт остаётся неназванным нигде. Была секция: Очередь.
- 2026-08-15 `rename-audiorecord` — ✨ Дать владельцу записи переименовать её. Причина: Слилась в audiorecord-actions 2026-08-15: правка заголовка живёт на экране одной записи вместе с возвратом в работу и журналом событий, и порознь каждое действие несло бы свою правку того же экрана. Была секция: Очередь.
- 2026-08-15 `resume-halted-record` — ✨ Вернуть остановленную запись в работу из приложения. Причина: Слилась в audiorecord-actions 2026-08-15: возврат в работу живёт на экране одной записи вместе с правкой заголовка и журналом событий. Была секция: Очередь.
- 2026-08-15 `record-events-screen` — ✨ Показывать журнал событий записи её владельцу. Причина: Слилась в audiorecord-actions 2026-08-15: журнал событий читается с экрана одной записи вместе с правкой заголовка и возвратом в работу. Была секция: Очередь.
+2 -2
View File
@@ -12,10 +12,10 @@
## Затрагивает
- заголовок авторизации у всех эндпоинтов `/api/`;
- заголовок авторизации у всех адресов приложения `/app/`;
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
обращения, и её миграция;
- эндпоинты выпуска, перечня и отзыва токена;
- адреса выпуска, перечня и отзыва токена`/app/me/tokens`;
- `docs/security.md` — второй способ представиться и хранение отпечатка;
- `README.md` — пример вызова API скриптом.
+82
View File
@@ -0,0 +1,82 @@
# ✨ Дать владельцу править запись, возвращать её в работу и видеть её путь
- **Тип:** feature
- **Категория:** Очередь — Три действия живут на экране одной записи, а заводит его список
- **Зачем:** С записью нельзя сделать ничего: заголовок ставит одна языковая модель, остановленную возвращает в работу только владелец сервиса в панели, а журнал событий пишется и не читается никем, кроме него же
Экран одной записи перестаёт быть окном просмотра: с него запись переименовывают,
возвращают в работу после отказа и смотрят её путь по рубежам.
Три действия собраны одной задачей, потому что живут на одном экране и стоят на
одном контракте: порознь каждое несло бы свой адрес и свою правку того же экрана.
Собраны они после решения владельца 2026-08-15, которым разведены колонки
заголовка и имени файла и назначено пространство адресов `/app/`.
**Переименование.** Колонка заголовка объявлена местом для «пользовательского или
сгенерированного» названия, а поставить его человеку нечем: заполняет её один
`llm-insights-adapter`. Отсюда же второй вопрос, который решается здесь: что
происходит с заголовком, поставленным человеком, когда модель посчитает свой.
**Возврат в работу.** Паспорт описывает сценарий «отказ на середине» и кончает
его тем, что владелец записи узнаёт о неудаче. Дальше пусто: разовый сбой
`ffmpeg` оставляет запись остановленной, пока владелец сервиса не откроет панель.
Само снятие остановки написано — `entity.Resume` чистит все три сторожа и ставит
время входа в рубеж заново. Цена названа прямо: повтор запускает платное
распознавание, и запускает его теперь пользователь, а не владелец сервиса —
значит у возвратов нужен предел, и его величину выбирает эта задача.
**Путь записи.** Журнал событий заполняется на смену рубежа, остановку и снятие
остановки, а читается только в панели: `architecture.md` говорит про него прямо,
что «экрана у него пока нет». Владельцу записи он нужен там, где карточка
отвечает «остановлена»: без пути «встала сразу» неотличимо от «висела два часа».
## Затрагивает
- `PATCH /app/audiorecords/{id}` — правка заголовка, и только его;
- `POST /app/audiorecords/{id}/resume` — возврат остановленной записи в работу;
- `GET /app/audiorecords/{id}/events` — события своей записи по времени;
- предел числа возвратов и место, где он считается: колонка записи либо журнал
событий;
- ответ карточки записи — признак того, можно ли вернуть запись в работу сейчас;
- шаг конвейера, кладущий посчитанный заголовок: признак того, что заголовок
поставлен человеком;
- экран одной записи из `records-list-screen`: три действия и показ пути;
- перевод рубежей и причин остановки в текст для человека — тот же словарь, что
на карточке;
- спека `pipeline` — возврат в работу нормирован там со стороны панели;
спека `storage` — журнал событий описан там как канал владельца сервиса;
правка записи её владельцем не нормирована нигде.
## Критерии приёмки
- Заголовок правится владельцем и переживает перезагрузку экрана, посчитанный
его не затирает, а пустой возвращает показ по имени файла отправителя. Оракул
— три теста: правка и чтение карточки; шаг расчёта заголовка по записи с
правленым заголовком; правка пустым значением и чтение списка.
- Заголовок ограничен длиной и очищен от управляющих знаков — теми же правилами,
какими приём чистит имя файла. Оракул — тест на строке сверх предела и со
знаками управления.
- Остановленная запись возвращается в работу с сохранённого рубежа с очищенными
сторожами, неостановленная отвечает отказом, а возвраты сверх предела — тоже,
и запись остаётся остановленной. Оракул — три теста: возврат записи с
накопленными отказами и признаком прежнего захвата, затем захват с того же
рубежа; возврат записи в работе; возвраты сверх предела.
- Владелец видит события своей записи по времени, и машинного текста отказа в
них нет: событие несёт причину остановки, а не сообщение зависимости. Оракул —
два теста: запись, прошедшая два рубежа и остановку; остановленная запись с
текстом ошибки в колонке.
- Чужая запись на всех трёх адресах отвечает тем же, чем несуществующая, — и
кодом, и телом. Оракул — тест на двух учётных записях, по разу на каждый
адрес.
## Рамки
Правку прочих полей записи не заводим: меняется один заголовок. Краткое описание
и темы остаются за языковой моделью — их правка руками это отдельное решение,
которого никто не принимал. Панель владельца сервиса остаётся как есть: она
возвращает записи без предела, потому что деньгами распоряжается он. Правил, по
которым отказ отличается от приговора, не трогаем — это
`failure-verdict-vs-retry`. Событий не добавляем и журнал не переписываем:
показываем записанное.
Берётся после `records-list-screen` — экран одной записи заводит она.
+17 -16
View File
@@ -4,38 +4,39 @@
- **Категория:** Очередь — Дедупликация ищет совпадение в пределах пользователя — то есть после владельца записи, и экономит деньги с первого дня приложения.
- **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
Повторная отправка того же файла возвращает прежнюю запись вместо второй задачи.
Повторная отправка того же файла возвращает прежнюю запись вместо второй.
Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по
совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт.
## Затрагивает
- `TranscribeService.createTranscribeJob` — единая точка приёма, через неё идут
оба входа;
- `TranscribeService.createRecord` — единственный путь, которым запись
появляется в хранилище;
- таблица `files`: колонка хеш-суммы, её миграция и индекс по паре «владелец,
хеш-сумма»;
- контракт `POST /api/audio`: ответ на повторный файл;
- ответ бота на повторно присланное голосовое;
- контракт `POST /app/audiorecords`: признак повторного файла в ответе — место
под него согласовано задачей `json-api-for-spa`;
- `docs/database.md` — представление данных.
## Критерии приёмки
- Повторная отправка того же файла тем же пользователем возвращает
идентификатор прежней задачи, второй задачи в базе не появляется. Оракул —
тест приёма: два вызова одним содержимым, в репозитории одна задача.
- Тот же файл от другого пользователя заводит свою задачу и своё распознавание.
Оракул — тест приёма с двумя владельцами: две задачи, тексты не разделяются.
идентификатор прежней записи с признаком повтора, второй записи в базе не
появляется. Оракул — тест приёма: два вызова одним содержимым, в репозитории
одна запись, во втором ответе признак повтора.
- Тот же файл от другого пользователя заводит свою запись и своё распознавание.
Оракул — тест приёма с двумя владельцами: две записи, тексты не разделяются.
- Повторный файл не остаётся вторым экземпляром в каталоге хранения. Оракул —
тест: после второго вызова в каталоге один файл.
- Незавершённая задача тоже узнаётся: повторная отправка отдаёт её состояние, а
не заводит соседнюю. Оракул — тест на задаче в состоянии `created`.
- Незавершённая запись тоже узнаётся: повторная отправка отдаёт её рубеж, а не
заводит соседнюю. Оракул — тест на записи с рубежом `uploaded`.
## Рамки
Владелец записи приходит из `record-ownership` — до неё дедупликация опирается
на того владельца, который уже есть. Хеш-сумма считается на сервере: подсчёт на
Владелец записи обязателен с 2026-08-14 — его держит схема хранилища, — и
дедупликация опирается на него. Хеш-сумма считается на сервере: подсчёт на
стороне приложения относится к `chunked-upload-choice`. Колонка добавляется во
всех четырёх местах пакета хранилища `internal/adapter/repo/pocketbase`
`applyToRecord`, `recordToJob`, `acquireColumns`, `acquiredRow` с её `toJob`, —
плюс шаг схемы (инвариант `CLAUDE.md`).
всех местах пакета хранилища `internal/adapter/repo/pocketbase`, которые
перечисляет инвариант `CLAUDE.md` «Колонки записи правятся в двух местах», плюс
шаг схемы.
+1 -1
View File
@@ -32,7 +32,7 @@
## Критерии приёмки
- Пути и методы объявлены в одном месте, и переименование пути роняет проверки.
Оракул — мутация: заменить `/api/audio` на `/api/upload`, прогнать
Оракул — мутация: заменить `/app/audiorecords` на `/app/uploads`, прогнать
`go test ./internal/controller/http/` и увидеть красное.
- Обработчик не пишет в журнал через стандартный `log`. Оракул —
`grep -rn 'log\.' internal/controller/http/*.go` без импорта `log/slog` пуст.
@@ -60,7 +60,8 @@ file name too long"`, и поля `file_ext` в ней нет вовсе.
## Рамки
Приведение пути не должно делать маршрут неразличимым: по журналу отличают
`/api/audio` от `/api/status/:id`. Найдено прогоном ревью задачи
Приведение пути не должно делать маршрут неразличимым: по журналу отличают приём
записи от чтения одной записи — `POST /app/audiorecords` от
`GET /app/audiorecords/{id}`. Найдено прогоном ревью задачи
`remove-telegram-intake` 2026-08-15, враждебный проход; дефект существовал до той
правки, и владелец решил вынести его задачей.
+138 -18
View File
@@ -1,7 +1,7 @@
# ✨ Свести приём и чтение записей к одному контракту для приложения
- **Тип:** feature
- **Категория:** Очередь — Единая точка трансляции доменной ошибки — база и для экранов, и для токенов; список своих записей заводится после владельца, а не до.
- **Категория:** Очередь — Проект экранов и адресов согласован с владельцем 2026-08-15: на этом контракте стоят каркас приложения и все его экраны, а стоящие выше правки входа и хранилища его не подпирают
- **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
Экраны заводят задачу, видят её состояние и листают список — всё через один
@@ -20,35 +20,144 @@
предъявления отвергает такого вызывающего раньше. Он становится достижимым вместе
с личными токенами и экранами приложения, то есть ровно здесь.
**Состав контракта владелец согласовал 2026-08-15**, разобрав его вместе с
экранами приложения. Четыре развилки закрыты решением:
- **Опрос `GET /api/status/:id` убирается целиком**, а с ним и имена его полей:
`job_id` зовётся `id`, текст переезжает на свой адрес. Стадия проекта —
стройка, данных на сервере нет, а внешней программы на прежнем контракте не
существует: своего токена у неё не было, и держать два адреса на один вопрос
не за чем. Ломка объявляется в спеке `intake`.
- **Запись подписывается именем файла, данным отправителем.** Имя ложится
**своей колонкой**`original_filename`, — а колонка заголовка остаётся под
название, которое дал человек либо посчитала языковая модель. Одной колонкой
на оба смысла посчитанный заголовок затирал бы то, по чему человек узнаёт свою
запись, и вернуть затёртое было бы неоткуда. Ответ несёт оба поля, а экран
показывает заголовок и подставляет имя файла, пока заголовка нет. Норма
`intake` сужается: имя не доходит до **имени файла** в хранилище и до журнала,
а в самой записи его видит один владелец. Отсюда обязанность приёма: длину
ограничить, управляющие знаки убрать.
- **Аудио отдаётся своим адресом**, а не ссылкой хранилища с токеном файла:
норма `storage` запрещает ссылке выходить в ответ отправителю.
- **Текст едет отдельным адресом**, а не вместе с карточкой: шестичасовая
расшифровка иначе задерживает показ шапки записи на мобильной сети.
Адреса приложения после этого такие:
| Метод и путь | Что отдаёт |
| --- | --- |
| `GET /app/me` | кто вошёл |
| `GET /app/config` | чем сервер ограничивает: потолок размера записи, частота опроса, перечень известных расширений, потолок тем |
| `POST /app/audiorecords` | список заведённых записей |
| `GET /app/audiorecords` | страницу своих записей, новые сверху |
| `GET /app/audiorecords/{id}` | карточку записи без текста |
| `GET /app/audiorecords/{id}/text` | текст названного вида — сплошной либо репликами со временем |
`GET /app/config` заводится здесь же, а не задачей экрана: пределы объявляет
сервер, и экран, знающий их своей константой, расходится с ним молча — до
первого отказа на гигабайтном файле, который человек уже успел отправить.
Приём стоит тем же адресом, что и список, и отличается только методом: он
**заводит аудиозапись**, а не кладёт файл. Прежнее имя `audio` называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь —
хотя запись он и создаёт.
Элемент списка несёт идентификатор, заголовок, имя файла отправителя, краткое
описание, темы, рубеж, признак остановки и её причину, длительность, размер и
время заведения.
Длительность с размером лежат сегодня строкой файла, а список по норме `storage`
читается без содержимого — значит оба переезжают колонками записи и заполняются
приёмом, который читает их у `ffprobe` и так.
Адреса записи названы `audiorecords` — по центральной сущности сервиса, а не
общим словом «записи»: последнее в хранилище значит строку любой коллекции.
Коллекция при этом зовётся `audio_records`, и расхождение намеренное: в схеме
имена в snake_case, у соседей по адресу — слитно.
**Приложение целиком уезжает из `/api/` в своё пространство `/app/`** — решение
владельца 2026-08-15. Пространство `/api/` принадлежит хранилищу: оно вешает
туда двенадцать наборов своих адресов (`settings`, `collections`, `files`,
`logs`, `realtime`, `batch`, `backups`, `crons`, `health`, `sql`,
`oauth2-redirect` и собственные адреса входа коллекций), и поменять этот префикс
нельзя — он литерал в `apis/base.go`, а не настройка. Свободных имён сегодня
хватает, но соседство остаётся: обновление библиотеки вправе занять новое имя
рядом с нашим, и разойдутся они молча. Прецедент в проекте уже принят — адреса
входа вынесены на `/auth/*` этим же доводом (спека `access`).
Цена переезда названа: правило неизвестного пути перечисляет теперь четыре корня
`/api/`, `/app/`, `/auth/` и `/_/` — вместо одного, а ограничитель частоты
хранилища, настроенный на `/api/`, наших адресов больше не покрывает, и своё
правило под `/app/` заводится здесь же.
Настройки пользователя лягут на `/app/me/settings`; собственный `/api/settings`
принадлежит хранилищу и остаётся ему.
## Затрагивает
- `POST /api/audio` и `GET /api/status/:id` коды ответа и форма ошибки;
- новый эндпоинт списка своих записей с постраничным чтением;
- `POST /app/audiorecords` — переезд приёма из `/api/audio`, коды ответа, форма
ошибки и форма успешного ответа;
- `GET /api/status/:id` — убирается вместе со своими именами полей;
- `GET /app/me` — кто вошёл;
- `GET /app/config` — пределы и свойства сервера для экранов;
- `GET /app/audiorecords` — список своих записей с постраничным чтением и отбором по
тому, в работе запись или нет;
- `GET /app/audiorecords/{id}` и `GET /app/audiorecords/{id}/text` — карточка и текст
названного вида, сплошной либо репликами со временем;
- слой предъявления сессии и своё правило ограничителя частоты — оба переезжают
с адресов на новый корень;
- колонки `original_filename`, длительности и размера у аудиозаписи, и шаг схемы
под них;
- `internal/entity` и три места правки колонок в `internal/adapter/repo/pocketbase`
— компилятор их расхождения не видит (инвариант «Колонки записи правятся в
двух местах» в [CLAUDE.md](../../CLAUDE.md));
- приём: имя файла отправителя в свою колонку, с ограничением длины и уборкой
управляющих знаков;
- единая точка отображения доменной ошибки в код ответа — её сегодня нет
([conventions/errors.md](../../docs/conventions/errors.md));
- `internal/contract` — доменные ошибки под отображение;
- `internal/contract` — доменные ошибки под отображение, отбор списка по
владельцу;
- `internal/controller/http` целиком;
- `docs/architecture.md`, раздел «Единые точки проекта».
- спека `intake` — новый адрес приёма, убранный опрос готовности и суженная
норма про имя отправителя;
- спека `access` — область слоя предъявления сессии названа там адресами
приложения;
- `docs/conventions/web-ui.md` — правило неизвестного пути перечисляет корни
сервиса, а не один `/api/`;
- `docs/architecture.md`, раздел «Единые точки проекта»;
- `docs/database.md` — новые колонки записи.
## Критерии приёмки
- Код ответа отвечает причине отказа, а не месту, где он случился: сбой базы при
чтении даёт `500`, а не `404`, негодный файл — `400` с человекочитаемым
текстом, а не `500`, а отказ приёма по пустому владельцу — `403`, как и отказ
слоя предъявления. Оракул — три теста: репозиторий, возвращающий ошибку
драйвера; файл, который отвергает разбор метаданных; вызов приёма без учётной
записи.
- Тело ошибки одной формы на всех эндпоинтах, не содержит сырого `err.Error()` и
опечатки `transcibe`. Оракул — тест на четырёх ветвях отказа (форма совпадает,
текста внутренней ошибки в теле нет) плюс пустой `grep -rn 'transcibe'
internal/`.
- Список записей отдаётся страницами и упорядочен по времени создания. Оракул —
тест на выборке больше страницы.
- Отображение ошибки живёт в одной функции, и она названа в
`docs/architecture.md`. Оракул — `task gate`, шаг `docs.py check`.
слоя предъявления. Тело ошибки при этом одной формы на всех адресах, без
сырого `err.Error()` и без опечатки `transcibe`, а отображение живёт в одной
функции, названной в `docs/architecture.md`. Оракул — три теста на код
(репозиторий с ошибкой драйвера; файл, который отвергает разбор метаданных;
вызов приёма без учётной записи), тест формы на четырёх ветвях отказа, пустой
`grep -rn 'transcibe' internal/` и `task gate`, шаг `docs.py check`.
- Список отдаётся страницами, новые сверху, и несёт заголовок, имя файла
отправителя, длительность, размер, рубеж с признаком остановки и темы, не
читая при этом ни расшифровки, ни структуры реплик. Оракул — два теста:
выборка больше страницы; запись с расшифровкой, у которой чтение списка не
трогает строку текста.
- Ответ приёма отдаёт список заведённых записей и место под признак повторного
файла, даже когда файл в запросе один. Оракул — тест приёма: тело ответа —
список из одного элемента, у элемента есть поле признака повтора.
файла, даже когда файл в запросе один, а имя файла отправителя лежит своей
колонкой — обрезанное по пределу и без управляющих знаков — при пустом
заголовке. Оракул — четыре теста приёма: тело ответа — список из одного
элемента с полем признака повтора; имя `разговор.mp3` доходит до
`original_filename`; колонка заголовка у принятой записи пуста; имя длиннее
предела и с управляющими знаками доходит обрезанным и очищенным.
- `GET /app/config` отдаёт потолок размера тем же числом, каким сервер отвергает
запись сверх него, а не своей копией. Оракул — тест: значение в ответе равно
`entity.MaxRecordSize`, и подмена константы меняет ответ.
- Карточка записи и её текст читаются порознь, а прежний опрос готовности
отвечает `404`: карточка несёт признак наличия текста, а текст отдаётся
названным видом — сплошным либо репликами со временем. Оракул — четыре теста:
карточка без поля текста; текст вида `transcript` сплошным; он же репликами со
временем; прежние адреса `GET /api/status/{id}` и `POST /api/audio` на
заведённой записи отвечают `404`.
## Рамки
@@ -62,3 +171,14 @@
Сюда же приехала опечатка `transcibe` в тексте ошибки приёма (нашёл проход `review-specs` на ревью change
`2026-08-11-fix-http-handler-tests`): в одиночку текст ошибки менять нельзя,
а здесь контракт переписывается целиком.
Экранов не делаем ни одного — их берут `spa-skeleton` и задачи экранов. Три
адреса контракт называет, но не реализует, потому что у каждого свой хозяин:
поток аудио `GET /app/audiorecords/{id}/audio``play-recording-in-app`, чтение и
запись настроек по адресу `/app/me/settings``settings-screen`, признак
повторного файла — `dedup-by-content-hash`. Заголовок и темы, которые считает
языковая модель, тоже не здесь: место под них в ответе есть, заполняет его
`llm-insights-adapter`. Имени файла отправителя он при этом не трогает — колонки
разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в
приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт
задача, которой это понадобится.
+15 -12
View File
@@ -8,30 +8,33 @@
## Затрагивает
- контракт `POST /api/audio`: несколько файлов в одной форме и ответ списком;
- `TranscribeService.createTranscribeJob` — заведение нескольких задач одним
- контракт `POST /app/audiorecords`: несколько файлов в одной форме и ответ
списком;
- `TranscribeService.createRecord` — заведение нескольких записей одним
запросом;
- экран загрузки: выбор нескольких файлов и показ их состояний;
- экран загрузки: выбор нескольких файлов и показ их рубежей;
- предел числа файлов и предел размера запроса на стороне сервера;
- `GET /app/config` — предел числа файлов, чтобы экран знал его до отправки;
- `docs/database.md` — предел числа файлов числом.
## Критерии приёмки
- Десять файлов одной формой заводят десять задач, и ответ отдаёт
идентификатор каждой. Оракул — тест приёма: в ответе десять записей, в
репозитории десять задач.
- Десять файлов одной формой заводят десять записей, и ответ отдаёт
идентификатор каждой. Оракул — тест приёма: в ответе десять элементов, в
репозитории десять записей.
- Негодный файл в пачке отклоняется поимённо, а годные соседи заводятся. Оракул
— тест на пачке из годного и негодного: одна задача заведена, второй элемент
— тест на пачке из годного и негодного: одна запись заведена, второй элемент
ответа несёт причину отказа.
- Одиннадцатый файл отклоняется до чтения содержимого, с названным пределом.
Оракул — тест на пачке из одиннадцати: задач не заведено, в теле ответа
Оракул — тест на пачке из одиннадцати: записей не заведено, в теле ответа
предел числом.
- Прежний вызов с одним файлом работает по-старому. Оракул — существующие тесты
приёма по HTTP.
## Рамки
Публичный контракт `POST /api/audio` меняется, а это необратимо — форма ответа
согласуется с человеком. Загрузка частями сюда не входит: это
`chunked-upload-choice`. Приём из Telegram не трогается — бот присылает файлы по
одному.
Публичный контракт `POST /app/audiorecords` меняется, а это необратимо. Форма
ответа при этом заново не согласуется: она сведена списком с местом под признак
повтора задачей `json-api-for-spa` — решением владельца от 2026-08-12 — как раз
для того, чтобы эта задача её не переписывала. Меняется число файлов в запросе,
не форма ответа. Загрузка частями сюда не входит: это `chunked-upload-choice`.
+7 -5
View File
@@ -18,12 +18,14 @@ ogg после конвертации и запись об объекте, —
- связь задачи с копией: сегодня `transcribe_jobs.file` — одно поле на одну
копию, и шаг схемы придётся добавлять;
- контракт HTTP API из `json-api-for-spa`адрес проигрываемой копии в ответе
о записи;
- контракт HTTP API из `json-api-for-spa`признак наличия аудио в ответе о
записи;
- экран одной записи из `records-list-screen`;
- отдачу файла хранилищем: `/api/files/...` отвечает на `Range` сам, но
`audio/ogg` не входит в перечень типов, которые хранилище отдаёт с
`Content-Disposition: inline`, — ogg-копия уходит вложением;
- `GET /app/audiorecords/{id}/audio` — свой адрес отдачи копии, с поддержкой
`Range`: ссылка хранилища в ответ не выходит, это норма `storage`. Отдачу
пишем сами, и оба свойства чужого адреса переходят к нам обязанностью — ответ
на `Range` и тип содержимого, при котором копия проигрывается, а не
скачивается вложением;
- периметр из [ADR-2026-08-12](../../docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md):
ссылка на файл открыта знанием записи, и экран делает её видимой странице;
- `docs/architecture.md` — какая копия считается проигрываемой.
+23 -15
View File
@@ -1,7 +1,7 @@
# ✨ Сделать экран списка своих записей и чтения текста
- **Тип:** feature
- **Категория:** Очередь — Список и чтение текста берутся после экрана загрузки — так записано в самой задаче.
- **Категория:** Очередь — Список и экран одной записи берутся после экрана загрузки
- **Зачем:** Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
Список своих записей открывается и листается, а готовый текст читается и
@@ -13,32 +13,40 @@
- новый экран списка и экран одной записи;
- постраничное чтение из контракта API;
- подпись записи в строке списка: заголовок, а пока его нет — имя файла
отправителя;
- чтение текста отдельным адресом и показ его репликами со временем;
- копирование текста в буфер обмена;
- показ записи, которая ещё в работе, — тем же состоянием, что на экране
загрузки.
- показ записи, которая ещё в работе, — тем же рубежом, что на экране загрузки.
## Критерии приёмки
- Список показывает записи вошедшего, новые сверху, и листается дальше первой
страницы. Оракул — тест экрана на подставном API с двумя страницами.
- Строка списка подписана заголовком, а у записи без заголовка — именем файла
отправителя. Оракул — тест на двух записях: с заголовком и без.
- Текст готовой записи открывается целиком, без деления на части. Оракул — тест
на записи с текстом длиннее предела сообщения Telegram: на экране весь текст.
на записи с текстом в сотни килобайт: на экране весь текст.
- Текст копируется одним действием. Оракул — тест: после действия в буфере обмена
тот же текст.
- Запись в работе показывается состоянием и доходит до текста без перезагрузки
экрана. Оракул — тест: подставной API отвечает `transcribe`, затем `done`;
экран переходит к тексту сам.
- Запись в работе показывается рубежом и доходит до текста без перезагрузки
экрана. Оракул — тест: подставной API отвечает рубежом `submitted`, затем
`done`; экран переходит к тексту сам.
**Учесть от ревью 2026-08-12:** имя файла в хранилище несёт хвост имени, данного
отправителем, — расширение берётся из него дословно, и запись
`разговор.тайное-слово` ложится на диск именем с этим хвостом. Сегодня наружу
оно не выходит: изъятие из инварианта приватности кончается журналом владельца.
Экран списка это меняет — имя попадает в ссылку на скачивание и в заголовок
ответа, то есть на страницу. Выхода за каталог хранения при этом нет, проверено
пятнадцатью враждебными именами.
`разговор.тайное-слово` ложится на диск именем с этим хвостом. Выхода за каталог
хранения при этом нет, проверено пятнадцатью враждебными именами. Наружу это имя
не выходит и теперь: аудио отдаётся своим адресом по идентификатору записи, а не
ссылкой хранилища, и имени файла в ней нет. На страницу попадает другое — имя,
данное отправителем, своей колонкой записи, и попадает намеренно: по нему человек
узнаёт свою запись. Отсюда обязанность экрана — показывать его текстом, а не
разметкой.
## Рамки
Поиска по тексту, переименования и удаления записей не делаем. Правки текста не
делаем — это граница из [паспорта](../../docs/passport.md): расшифровку отдаём как
есть.
Поиска по тексту не делаем. Переименование записи, возврат её в работу и журнал
событий — `audiorecord-actions`; удаление — `delete-record`, проигрывание —
`play-recording-in-app`. Правки текста
не делаем — это граница из [паспорта](../../docs/passport.md): расшифровку отдаём
как есть.
+11 -9
View File
@@ -12,18 +12,19 @@
## Затрагивает
- `TranscribeService.createTranscribeJob` — проверка длительности и веса до
записи файла на диск;
- контракт `POST /api/audio`: код и тело ответа на запись сверх потолка;
- ответ бота на слишком длинную запись;
- `TranscribeService.createRecord` — проверка длительности и веса до записи
файла в хранилище;
- контракт `POST /app/audiorecords`: код и тело ответа на запись сверх потолка;
- `GET /app/config` — потолок длительности рядом с потолком размера, чтобы экран
отклонял такую запись до отправки;
- секция конфигурации: потолок длительности и потолок веса;
- `docs/database.md` — настройки с числовым значением.
## Критерии приёмки
- Запись длиннее потолка отклоняется на приёме: задача не заводится, файл на
диске не остаётся. Оракул — тест приёма на записи сверх потолка: задач ноль,
каталог хранения пуст.
- Запись длиннее потолка отклоняется на приёме: аудиозапись не заводится, файл в
хранилище не остаётся. Оракул — тест приёма на записи сверх потолка: записей
ноль, каталог хранения пуст.
- Отправитель видит человекочитаемый текст с названным пределом, а не код
ошибки. Оракул — тест: в теле ответа предел числом, текста внутренней ошибки
нет.
@@ -36,5 +37,6 @@
Берётся после `intake-limits-measure`: до неё потолок брать неоткуда. Длительность
известна из `ffprobe` — значит, проверка идёт после чтения метаданных, но до
записи в базу. Предел веса на стороне сервера уже есть
(`router.MaxMultipartMemory`), и он не отменяется, а дополняется.
записи в базу. Предел размера на стороне сервера уже есть`entity.MaxRecordSize`
стоит и у поля файла, и у тела запроса приёма, — и он не отменяется, а
дополняется потолком длительности.
+7 -2
View File
@@ -22,14 +22,19 @@
```
/auth/login → 302 ... http.route=/auth/login http.status_code=302
/api/audio → 404 ... http.route=/api/audio http.status_code=0
/app/audiorecords → 404 ... http.route=/app/audiorecords http.status_code=0
/nope → 404 ... http.route=/nope http.status_code=0
POST /api/audio без сессии → 401 ... http.status_code=0
POST /app/audiorecords без сессии → 401 ... http.status_code=0
```
Первый обработчик отвечает сам и код пишет верно; три остальных отвергнуты
роутером и дают ноль.
Прогон снят до того, как приложение переехало из `/api/` в `/app/` решением
владельца 2026-08-15: тогда те же строки стояли с адресом `/api/audio`. Предмет
находки от переезда не зависит — ноль пишет роутер, а не обработчик, — и
воспроизводится она с любым адресом, который роутер отверг.
## Затрагивает
- слой журнала запроса в `main.go`: место, где снимается код ответа;
+10 -10
View File
@@ -1,13 +1,12 @@
# ✨ Собрать каркас приложения и раздать его из бинарника
- **Тип:** feature
- **Категория:** Очередь — Каркас приложения: экранов нет и собирать их нечем, а на экране стоят настройки, удаление, уровни текста и статистика.
- **Категория:** Очередь — Каркас идёт сразу за контрактом: на нём стоят все экраны, а сам он ждёт готовой формы ответов
- **Зачем:** Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
Каркас готовит четыре экрана — загрузку, состояние задачи, чтение текста и
список, — но сам по себе пользователю ничего не даёт. Видимое от него одно:
приложение открывается и достаёт данные с живого сервера, а не отдаёт пустую
страницу.
Каркас готовит экраны — загрузку, рубеж записи, чтение текста и список, — но сам
по себе пользователю ничего не даёт. Видимое от него одно: приложение
открывается и достаёт данные с живого сервера, а не отдаёт пустую страницу.
Фреймворк выбран 2026-08-11 — Vue 3 с роутером пятой версии и сборкой Vite
([ADR](../../docs/adr/ADR-2026-08-11-spa-on-vue.md)); правила кода приложения
@@ -16,8 +15,9 @@
## Затрагивает
- новый каталог фронтенда: исходники, зависимости, конфигурация сборки;
- роут, отдающий `index.html` на неизвестный путь вне `/api/`: адреса маршрутов
обычные, а не после решётки;
- роут, отдающий `index.html` на неизвестный путь вне корней сервиса — `/api/`
у хранилища, `/app/` у приложения, `/auth/` у входа, `/_/` у панели, плюс
`/health` и `/metrics`: адреса маршрутов обычные, а не после решётки;
- `Taskfile.yml` — шаг сборки статики и его место в `task gate` и `task image`;
- `Dockerfile` — сборка статики внутри образа, чтобы выкладка не зависела от
машины разработчика;
@@ -38,9 +38,9 @@
- Шаг сборки статики входит в `task gate` и краснеет при ошибке сборки. Оракул —
намеренно сломанный исходник роняет `task gate`.
- Обновление страницы на любом маршруте приложения открывает тот же экран, а
адрес внутри `/api/` в приложение не проваливается. Оракул — тест на два
запроса: неизвестный путь вне `/api/` отдаёт разметку, неизвестный путь внутри
`/api/` отдаёт ошибку контракта.
адрес внутри корня сервиса в приложение не проваливается. Оракул — тест на три
запроса: неизвестный путь вне корней отдаёт разметку, неизвестный путь внутри
`/api/` и внутри `/app/` отдаёт ошибку контракта.
## Рамки
+20 -17
View File
@@ -1,36 +1,39 @@
# ✨ Сделать экран загрузки записи и её состояния
- **Тип:** feature
- **Категория:** Очередь — Первое, ради чего приложение открывают; требует каркаса и контракта, оба выше.
- **Категория:** Очередь — Первое, ради чего приложение открывают; требует каркаса и контракта, оба выше
- **Зачем:** Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
Экран принимает файл и заводит задачу, а её состояние обновляется само, пока
задача не дошла до `done` или `failed`.
Экран принимает файл и заводит запись, а её рубеж обновляется сам, пока запись
не дошла до конечного рубежа `done` либо не встала признаком остановки.
Берётся после `spa-skeleton` и `json-api-for-spa`.
## Затрагивает
- новый экран приложения: выбор файла, отправка, показ состояния;
- опрос состояния задачи и его остановка;
- новый экран приложения: выбор файла, отправка, показ рубежа;
- опрос рубежа записи и его остановка;
- показ отказа человекочитаемым текстом из контракта API;
- предел размера загружаемого файла на стороне приложения и на стороне сервера
(`router.MaxMultipartMemory`, сегодня 32 МиБ);
- предел размера записи на стороне приложения: экран берёт его из
`GET /app/config`, где сервер отдаёт своё же число;
- `docs/database.md` — частота опроса числом.
## Критерии приёмки
- Выбранный файл уходит на сервер и заводит задачу; экран сразу показывает её
состояние. Оракул — тест экрана на подставном API: после отправки на экране
идентификатор задачи и состояние `created`.
- Опрос сам прекращается, когда задача дошла до `done` или `failed`. Оракул —
тест: после ответа `done` новых запросов к API нет.
- Отказ задачи показывается человекочитаемым текстом, а не кодом и не сырой
ошибкой. Оракул — тест на ответе с состоянием `failed`.
- Выбранный файл уходит на сервер и заводит запись; экран сразу показывает её
рубеж. Оракул — тест экрана на подставном API: после отправки на экране
идентификатор записи и рубеж `uploaded`.
- Опрос сам прекращается, когда запись дошла до `done` либо встала признаком
остановки. Оракул — два теста: после ответа с рубежом `done` новых запросов к
API нет; после ответа с признаком остановки — тоже.
- Остановленная запись показывается человекочитаемым текстом, а не кодом и не
сырой ошибкой; машинного текста отказа контракт не отдаёт вовсе, поэтому текст
строится из причины остановки. Оракул — тест на ответе с признаком остановки и
причиной.
- Файл больше предела отклоняется на экране до отправки, с названным числом, и
предел экран берёт у сервера, а не держит своей константой. Оракул — тест на
файле сверх предела: запроса на загрузку нет, на экране предел числом, и
смена предела на стороне сервера меняет это число.
предел экран берёт из `GET /app/config`, а не держит своей константой. Оракул
— тест на файле сверх предела: запроса на загрузку нет, на экране предел
числом, и другое число в ответе `config` меняет показанное.
## Рамки