diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index 4b2e568..f78e311 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -64,8 +64,13 @@ экранов и какие — не здесь: состав нормирует спека приложения, а до неё его держит [spa-skeleton](../../tasks/items/spa-skeleton.md). - **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование - к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`; - пути внутри `/api/` в приложение не проваливаются никогда. + к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не + `404`; путь внутри корня в приложение не проваливается никогда. Корней + сегодня четыре — `/api/` у хранилища, `/app/` у приложения, `/auth/` у входа, + `/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение + уехало из общего `/api/` решением владельца 2026-08-15: пространство + принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с + нашим. - **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не получает от предыдущего: приложение открывают по ссылке и обновляют страницу посередине. diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 8c0daec..5aec6f3 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -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) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе. diff --git a/tasks/REJECTED.md b/tasks/REJECTED.md index 0161954..90872be 100644 --- a/tasks/REJECTED.md +++ b/tasks/REJECTED.md @@ -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: журнал событий читается с экрана одной записи вместе с правкой заголовка и возвратом в работу. Была секция: Очередь. diff --git a/tasks/items/api-tokens.md b/tasks/items/api-tokens.md index 49d1cb2..6a5b521 100644 --- a/tasks/items/api-tokens.md +++ b/tasks/items/api-tokens.md @@ -12,10 +12,10 @@ ## Затрагивает -- заголовок авторизации у всех эндпоинтов `/api/`; +- заголовок авторизации у всех адресов приложения `/app/`; - таблица токенов: владелец, имя, отпечаток, время выпуска и последнего обращения, и её миграция; -- эндпоинты выпуска, перечня и отзыва токена; +- адреса выпуска, перечня и отзыва токена — `/app/me/tokens`; - `docs/security.md` — второй способ представиться и хранение отпечатка; - `README.md` — пример вызова API скриптом. diff --git a/tasks/items/audiorecord-actions.md b/tasks/items/audiorecord-actions.md new file mode 100644 index 0000000..cb83198 --- /dev/null +++ b/tasks/items/audiorecord-actions.md @@ -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` — экран одной записи заводит она. diff --git a/tasks/items/dedup-by-content-hash.md b/tasks/items/dedup-by-content-hash.md index 92b2971..fc0fcc3 100644 --- a/tasks/items/dedup-by-content-hash.md +++ b/tasks/items/dedup-by-content-hash.md @@ -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` «Колонки записи правятся в двух местах», плюс +шаг схемы. diff --git a/tasks/items/http-transport-nits.md b/tasks/items/http-transport-nits.md index 83a4aea..6ce0472 100644 --- a/tasks/items/http-transport-nits.md +++ b/tasks/items/http-transport-nits.md @@ -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` пуст. diff --git a/tasks/items/journal-fields-bounded-by-service.md b/tasks/items/journal-fields-bounded-by-service.md index 7d7a49c..db852f5 100644 --- a/tasks/items/journal-fields-bounded-by-service.md +++ b/tasks/items/journal-fields-bounded-by-service.md @@ -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, враждебный проход; дефект существовал до той правки, и владелец решил вынести его задачей. diff --git a/tasks/items/json-api-for-spa.md b/tasks/items/json-api-for-spa.md index 9b5f24c..31390a9 100644 --- a/tasks/items/json-api-for-spa.md +++ b/tasks/items/json-api-for-spa.md @@ -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`. Имени файла отправителя он при этом не трогает — колонки +разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в +приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт +задача, которой это понадобится. diff --git a/tasks/items/multi-file-upload.md b/tasks/items/multi-file-upload.md index db8e1e8..02d242f 100644 --- a/tasks/items/multi-file-upload.md +++ b/tasks/items/multi-file-upload.md @@ -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`. diff --git a/tasks/items/play-recording-in-app.md b/tasks/items/play-recording-in-app.md index 073c703..925600b 100644 --- a/tasks/items/play-recording-in-app.md +++ b/tasks/items/play-recording-in-app.md @@ -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` — какая копия считается проигрываемой. diff --git a/tasks/items/records-list-screen.md b/tasks/items/records-list-screen.md index 50741b5..21d2af3 100644 --- a/tasks/items/records-list-screen.md +++ b/tasks/items/records-list-screen.md @@ -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): расшифровку отдаём +как есть. diff --git a/tasks/items/reject-oversized-recording.md b/tasks/items/reject-oversized-recording.md index b73715b..19a61ab 100644 --- a/tasks/items/reject-oversized-recording.md +++ b/tasks/items/reject-oversized-recording.md @@ -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` +стоит и у поля файла, и у тела запроса приёма, — и он не отменяется, а +дополняется потолком длительности. diff --git a/tasks/items/response-code-in-journal.md b/tasks/items/response-code-in-journal.md index 747f97a..d1066a6 100644 --- a/tasks/items/response-code-in-journal.md +++ b/tasks/items/response-code-in-journal.md @@ -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`: место, где снимается код ответа; diff --git a/tasks/items/spa-skeleton.md b/tasks/items/spa-skeleton.md index 5215dd8..43fbf0a 100644 --- a/tasks/items/spa-skeleton.md +++ b/tasks/items/spa-skeleton.md @@ -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/` отдаёт ошибку контракта. ## Рамки diff --git a/tasks/items/upload-and-status-screen.md b/tasks/items/upload-and-status-screen.md index 4bba510..1943537 100644 --- a/tasks/items/upload-and-status-screen.md +++ b/tasks/items/upload-and-status-screen.md @@ -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` меняет показанное. ## Рамки