tasks: целевая картина пересобрана — три цели, тринадцать задач, сдвинутые границы

- паспорт: сервис объявлен архивом с бессрочным хранением записей и текстов,
  машинная вычитка расшифровки внутри границ, приложение — основной вход;
  добавлены две границы: не файловое хранилище общего назначения и не биллинг;
- заведены цели upload-reliability, user-settings, usage-stats и тринадцать
  задач; очередь пересобрана — сперва починки, затем разведки о хранилище,
  затем доступ и владелец, и только потом экраны;
- архитектура: четыре новых открытых вопроса — приём большого файла, учёт
  расхода, срок хранения, потолок шести часов.
This commit is contained in:
av
2026-08-11 10:05:22 +03:00
parent 2333e80633
commit d2c85af80a
28 changed files with 730 additions and 61 deletions
+17 -5
View File
@@ -134,7 +134,18 @@
текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет.
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
потолок проекта — шесть часов, и он взят с запасом, а не замером.
- **Приём большого файла.** Форма читается целиком, предел
`router.MaxMultipartMemory` — 32 МиБ, обрыв начинает загрузку заново.
Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет
публичный контракт приёма и потому идёт через решение в `adr/`.
- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
@@ -149,7 +160,8 @@
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет.
- **Выводы из текста.** Заголовок, пересказ и темы решено считать внешним
сервисом с OpenAI-совместимым интерфейсом. Появляется пятая внешняя
зависимость, платная, и текст расшифровки начинает уходить ещё на одну
сторону — сдвиг периметра [security.md](security.md).
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания.
+54 -22
View File
@@ -6,38 +6,51 @@
## Цель
Превращать записанную речь в текст, который можно читать и искать.
Превращать записанную речь в текст, который можно читать и искать, и хранить
этот текст вместе с записью.
**Сервис — архив, и это решено 2026-08-11.** Прежде граница читалась «отдаём
текст и на этом заканчиваем»; теперь расшифровки и исходные записи лежат
бессрочно, а список отбирается по темам. Причина в основном сценарии: семейный
архив загружают один раз, а возвращаются к нему годами.
**Потребители** — список закрытый: он определяет, что считать нужным, а что
интересным.
| Кто | Что ему нужно от нас |
| --- | --- |
| Владелец сервиса | Отправить голосовое сообщение из Telegram и получить текст ответом. Работает сегодня |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст. Приложение ставится на телефон; каждый видит только свои записи |
| Внешняя программа | Отдать файл по HTTP и опросить готовность. Работает сегодня, без разграничения доступа |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
несколько часов через Telegram не проходит вовсе.
Цель достигнута, когда:
- запись любого распространённого формата принимается без предварительной
подготовки, включая дорожку из видео;
- запись длиной в несколько часов доходит до текста, а не прерывается ошибкой при
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при
достижении предела;
- сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram, и человек
узнаёт о его готовности, не держа приложение открытым.
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
узнаёт о его готовности, не держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится
приглашение ещё одного человека.
## Что целью не является
Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
через границу.
- **Редактор текста.** Расшифровку отдаём как есть; правка, разметка, экспорт в
форматы документов — не наша работа.
- **Хранилище записей.** Отдаём текст и на этом заканчиваем: библиотекой, архивом
и поиском по прошлым записям сервис не становится. Сколько запись лежит на
диске после обработки — вопрос срока хранения, а его нет вовсе:
[database.md](database.md), «Представление данных».
- **Правка текста руками.** Машинную вычитку расшифровки отдаём — литературный
текст стоит рядом с сырым, — а редактором не становимся: текст руками не
правим, не размечаем и не экспортируем в форматы документов. Граница сдвинута
2026-08-11: до того запрет читался «расшифровку отдаём как есть».
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
@@ -51,20 +64,39 @@
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем — граница цели
[web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов, папками и общим доступом к чужим
записям сервис не становится.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв
доступа в Authelia.
## Типовые сценарии
1. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
Первые два — основные, и сегодня не работает ни один: приложения нет.
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
Загрузка показывает ход. Файл, который уже загружали, не грузится второй
раз. Когда текст готов, приходит уведомление; в списке запись видна
заголовком и темами.
2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями.
2. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению.
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio`, получает идентификатор
задачи и опрашивает `GET /api/status/:id`, пока не увидит `done` и текст.
4. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь Telegram получает сообщение о том, что
именно не вышло, и предложение повторить.
несколькими частями. Работает сегодня.
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Работает сегодня, но без токена и без разграничения
доступа.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить.
## Референсы
+17 -4
View File
@@ -21,24 +21,37 @@
## Очередь
- [🐞 Не писать имя файла пользователя в журнал](items/no-user-filename-in-log.md) — Приём кладёт имя файла, данное пользователем, в журнал контейнера на каждой принятой записи — инвариант приватности объявляет это критическим и необратимым.
- [🧹 Сравнивать доменные ошибки через errors.As](items/errors-as-instead-of-typecast.md) — NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча.
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
- [🔬 Админка PocketBase: данные, пользователи и файлы](items/pocketbase-admin-fit.md) — Переход на PocketBase решён ради его панели администратора, а что она даёт по данным, пользователям и файлам на диске — не проверено.
- [🔬 Очередь задач: своя таблица или готовая библиотека](items/job-queue-choice.md) — Очередь написана вручную: захват двумя запросами без транзакции, протухание временем, опрос раз в секунду вхолостую тремя воркерами.
- [🧹 Перевести хранилище на встроенный PocketBase](items/pocketbase-storage.md) — Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего.
- [🧹 Перевести хранилище на встроенный PocketBase](items/pocketbase-storage.md) — Из трёх доводов за перевод остался один: учётные записи ушли к Authelia, страницу статистики рисует само приложение, а панель администратора не проверена.
- [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- [🔬 Выбор фреймворка для приложения](items/spa-framework-choice.md) — Конвенция веб-UI написана под htmx, а решено делать SPA: до выбора фреймворка не заводится ни сборка, ни первый экран.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
- [ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
- [🧹 Сравнивать доменные ошибки через errors.As](items/errors-as-instead-of-typecast.md) — NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча.
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
+6 -3
View File
@@ -22,21 +22,24 @@
## Запланировано
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
## Направления
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- [🎯 Запись длиной в несколько часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны.
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
## Сопровождение
- [🎯 Состояние сервиса видно без чтения логов](items/service-observability.md) — Отказ замечает пользователь, а не владелец: оповещения нет, а путь записи по конвейеру собирается глазами по логам контейнера.
- [🎯 Владелец видит, кто сколько загрузил и во что это обошлось](items/usage-stats.md) — Распознавание и языковая модель оплачиваются по факту, а счёт приходит одной суммой: кто её набрал, из сервиса не выясняется.
## Готово
- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Основной вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой.
- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Первый вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой.
- 2025-08-08 `api-transcription` — Запись, отданная по HTTP, возвращается текстом. Программный вход: файл отдаётся формой, готовность и текст забираются опросом статуса задачи.
+37
View File
@@ -0,0 +1,37 @@
# ✨ Сделать страницу статистики для владельца
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- **Теги:** goal:usage-stats
Двигает пункты 1, 2 и 3 «Завершения» цели: расход по каждому пользователю виден
на странице, и открывается она только владельцу сервиса.
## Затрагивает
- новый экран приложения: таблица пользователей с объёмом, минутами и расходом,
накопительно и за период;
- эндпоинт сводки потребления с отбором по периоду;
- признак владельца сервиса: откуда он берётся — из группы OIDC или из
конфигурации;
- `docs/security.md` — второй уровень доступа помимо «свой или чужой».
## Критерии приёмки
- Владелец видит на странице строку по каждому пользователю с объёмом, минутами
и расходом. Оракул — тест экрана на подставном API: три пользователя, три
строки, числа совпадают с ответом.
- Обычный пользователь получает отказ и на странице, и на эндпоинте сводки, а не
пустую таблицу. Оракул — тест эндпоинта под обычной учётной записью: `403`.
- Отбор по периоду меняет числа. Оракул — тест на выборке за месяц и за всё
время.
- Страница не показывает ни имён файлов, ни текстов записей — только
идентификаторы и числа. Оракул — тест ответа сводки: полей с именем и текстом
в нём нет.
## Рамки
Берётся после `usage-accounting` — до неё показывать нечего. Управления
пользователями на странице не делаем: заводит и отключает их Authelia. Цен и
пересчёта в деньги здесь нет.
+41
View File
@@ -0,0 +1,41 @@
# ✨ Пускать скрипты в API по личным токенам
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- **Теги:** goal:multi-user
Двигает пункты 1 и 6 «Завершения» цели: запрос без токена не проходит (пункт 1),
а скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
записи (пункт 6).
Токен принадлежит учётной записи и даёт ровно её права: записи, заведённые по
токену, видны владельцу в приложении, и наоборот.
## Затрагивает
- заголовок авторизации у всех эндпоинтов `/api/`;
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
обращения, и её миграция;
- эндпоинты выпуска, перечня и отзыва токена;
- экран настроек — место, где токен выпускают и отзывают;
- `docs/security.md` — второй способ представиться и хранение отпечатка;
- `README.md` — пример вызова API скриптом.
## Критерии приёмки
- Запрос с годным токеном заводит задачу от имени его владельца. Оракул — тест
API: задача в репозитории с владельцем токена.
- Запрос без токена и с отозванным токеном получает `401` и задачи не заводит.
Оракул — тест на трёх случаях: нет заголовка, чужая строка, отозванный токен.
- Полное значение токена показывается один раз при выпуске, в базе лежит только
отпечаток. Оракул — тест: повторное чтение токена отдаёт имя и отпечаток,
значение отсутствует, плюс поиск значения по логу пуст.
- Токен не попадает ни в журнал, ни в текст ошибки. Оракул — тест приёма с
токеном: в перехваченном журнале значения нет.
## Рамки
Учётные записи по-прежнему заводит Authelia — свою регистрацию не делаем.
Сроков жизни и областей действия у токена не заводим: он даёт права владельца
целиком. Берётся после `oidc-login`: до неё представляться некому.
+29
View File
@@ -0,0 +1,29 @@
# 🔬 Загрузка большого файла частями
- **Тип:** research
- **Категория:** Очередь
- **Зачем:** Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
Шестичасовая диктофонная запись и видео из семейного архива весят гигабайты, а
уходят с телефона по сотовой сети. Сегодня приём читает форму целиком и держит
её в памяти до предела `router.MaxMultipartMemory`; что делать с обрывом,
неизвестно.
Решено начать с одного запроса, а докачку разобрать отдельно — это она.
## Вопрос
Чем чинить обрыв загрузки большого файла: готовым протоколом докачки, своей
нарезкой на части поверх обычной формы или пределом размера с отказом, и во что
каждый вариант обходится на стороне приложения, сервера и обратного прокси.
## Куда ляжет ответ
`docs/research/upload.md` — вариантами с ценой каждого. Выбор оформляется
решением в `docs/adr/`, потому что меняет публичный контракт приёма.
## Рамки
Ответ учитывает обратный прокси перед сервисом: его предел размера тела и
таймаут — часть цены. Хеш-сумма на стороне приложения разбирается здесь же:
она решает, можно ли пропустить загрузку целиком.
+41
View File
@@ -0,0 +1,41 @@
# ✨ Узнавать уже загруженный файл по хеш-сумме
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
- **Теги:** goal:upload-reliability
Двигает пункт 1 «Завершения» цели: повторная отправка того же файла возвращает
прежнюю запись вместо второй задачи.
Совпадение ищется **в пределах одного пользователя**: чужая расшифровка по
совпадению хеш-суммы не отдаётся и о её существовании отправитель не узнаёт.
## Затрагивает
- `TranscribeService.createTranscribeJob` — единая точка приёма, через неё идут
оба входа;
- таблица `files`: колонка хеш-суммы, её миграция и индекс по паре «владелец,
хеш-сумма»;
- контракт `POST /api/audio`: ответ на повторный файл;
- ответ бота на повторно присланное голосовое;
- `docs/database.md` — представление данных.
## Критерии приёмки
- Повторная отправка того же файла тем же пользователем возвращает
идентификатор прежней задачи, второй задачи в базе не появляется. Оракул —
тест приёма: два вызова одним содержимым, в репозитории одна задача.
- Тот же файл от другого пользователя заводит свою задачу и своё распознавание.
Оракул — тест приёма с двумя владельцами: две задачи, тексты не разделяются.
- Повторный файл не остаётся вторым экземпляром в каталоге хранения. Оракул —
тест: после второго вызова в каталоге один файл.
- Незавершённая задача тоже узнаётся: повторная отправка отдаёт её состояние, а
не заводит соседнюю. Оракул — тест на задаче в состоянии `created`.
## Рамки
Владелец записи приходит из `record-ownership` — до неё дедупликация опирается
на того владельца, который уже есть. Хеш-сумма считается на сервере: подсчёт на
стороне приложения относится к `chunked-upload-choice`. Колонка добавляется во
всех четырёх местах репозитория SQLite (инвариант `CLAUDE.md`).
+41
View File
@@ -0,0 +1,41 @@
# ✨ Слать готовый текст на почту из учётной записи
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
- **Теги:** goal:ready-notification
Двигает пункты 1, 2 и 3 «Завершения» цели: готовый текст и отказ доходят
письмом, а адрес берётся у учётной записи, а не из общего конфига.
Почта — второй канал рядом с тем, что заводит `ntfy-delivery`; выбор канала
остаётся в той же единой точке, что и сейчас.
## Затрагивает
- реализация интерфейса отправителя уведомления — почтовый адаптер;
- адрес почты из данных учётной записи OIDC и его хранение;
- выбор канала по настройке пользователя в `completeJob` и `failJob`;
- секция конфигурации: сервер отправки почты, учётные данные, адрес отправителя;
- `docs/architecture.md` — внешняя зависимость и чем она отказывает;
- `docs/security.md` — текст расшифровки уходит на почтовый сервер.
## Критерии приёмки
- Задача, дошедшая до `done` у пользователя с выбранной почтой, отправляет одно
письмо на адрес владельца. Оракул — тест с подставным отправителем: ровно один
вызов, адрес владельца, текст задачи в теле.
- Недоступный почтовый сервер не мешает задаче завершиться. Оракул — тест с
отправителем, возвращающим ошибку: задача в `done`, в журнале одна запись
уровня `WARN`.
- Отказ задачи доходит письмом тем же человекочитаемым текстом, что видит
пользователь Telegram. Оракул — тест на ветке `failJob`.
- Пароль почтового сервера не попадает ни в журнал, ни в текст письма. Оракул —
тест отправки с перехваченным журналом.
## Рамки
Берётся после `ntfy-delivery` — интерфейс отправителя заводит она — и после
`settings-screen`, где канал выбирают. Своего почтового сервера не поднимаем.
Длинный текст в письме не делится на части: предел сообщения есть у Telegram, а
не у почты.
+38
View File
@@ -0,0 +1,38 @@
# ✨ Отдавать вычитанный текст рядом с сырым
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
- **Теги:** goal:text-insights
Двигает пункт «Завершения» цели про литературный текст: у записи появляется
второй уровень — тот же разговор, вычитанный до читаемого вида.
Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст
остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга.
## Затрагивает
- колонка литературного текста у задачи и её миграция;
- контракт чтения записи: поле рядом с сырым текстом;
- шаг выводов из текста: ещё одно обращение к модели;
- экран чтения записи: переключение между уровнями;
- ответ бота: какой из уровней уходит в Telegram.
## Критерии приёмки
- У дошедшей до `done` записи есть литературный текст, а сырой остался
нетронутым. Оракул — тест конвейера на подставной модели: оба поля заполнены,
сырое совпадает с ответом распознавания.
- Отказ модели на вычитке не роняет задачу и не портит сырой текст. Оракул —
тест с моделью, возвращающей ошибку: задача в `done`, сырой текст на месте.
- Выключенный в настройках уровень не запрашивается и остаётся пустым. Оракул —
тест с выключенной вычиткой: обращений за ней нет.
- Экран показывает оба уровня с переключением между ними. Оракул — тест экрана
на подставном API.
## Рамки
Ручной правки текста человеком не делаем — это граница паспорта: сервис отдаёт
машинную вычитку, а редактором не становится. Берётся после
`llm-insights-adapter`: клиент модели заводит она.
+47
View File
@@ -0,0 +1,47 @@
# ✨ Считать заголовок, темы и пересказ внешней моделью
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
- **Теги:** goal:text-insights
Двигает пункты 1, 3, 4 и 5 «Завершения» цели: у готовой записи появляются
заголовок (1), пересказ (3) и темы (4), а отказ и молчание модели не роняют
задачу (5).
Здесь появляется пятая внешняя зависимость — языковая модель с
OpenAI-совместимым интерфейсом за шлюзом bifrost, — и текст расшифровки уходит
ещё на одну сторону.
## Затрагивает
- `internal/contract` — интерфейс расчёта выводов из текста;
- новый адаптер OpenAI-совместимого клиента и подставной адаптер для тестов;
- конвейер: шаг после `transcribe`, его состояние и место в цепочке;
- колонки заголовка, тем и пересказа у задачи и их миграция;
- контракт чтения записи: новые поля ответа;
- секция конфигурации: адрес шлюза, имя модели, ключ;
- `docs/architecture.md` — внешняя зависимость и чем она отказывает;
- `docs/security.md` — текст расшифровки уходит на внешний сервис.
## Критерии приёмки
- Дошедшая до `done` запись несёт заголовок в одну строку, список тем и пересказ
в два-три предложения. Оракул — тест конвейера на подставной модели: три поля
заполнены, заголовок без переносов строки.
- Отказ и молчание модели не роняют задачу: текст доходит до человека без
выводов. Оракул — тест с моделью, возвращающей ошибку и таймаут: задача в
`done`, текст на месте, в журнале одна запись уровня `WARN`.
- Уровень, выключенный в настройках пользователя, у модели не запрашивается, а
настройка берётся та, что действовала на приёме записи. Оракул — тест с
выключенными темами и тест с настройкой, изменённой после приёма: вызовов за
темами нет в обоих случаях.
- Ключ и адрес шлюза не попадают ни в журнал, ни в ответ. Оракул — тест шага с
перехваченным журналом.
## Рамки
Литературный текст сюда не входит — это `literary-text-level`. Своей модели не
держим и промптов в код не зашиваем сверх необходимого. Прогон на боевом ключе
ради проверки запрещён: подставной адаптер. Обращение к модели платное, и
объём расхода учитывает `usage-accounting`.
+47
View File
@@ -0,0 +1,47 @@
# ✨ Резать длинную запись на фрагменты и продолжать с места остановки
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
- **Теги:** goal:long-recordings
Двигает пункты 3, 5 и 6 «Завершения» цели: запись в пределах потолка доходит до
текста целиком, долгая задача не занимает воркер на часы, а перезапуск на
середине продолжает работу с первого неотмеченного фрагмента.
Запись делится на фрагменты, каждый распознаётся отдельно, готовый фрагмент
отмечается в базе. После перезапуска работа продолжается с первого неотмеченного, а
текст собирается из фрагментов по порядку.
## Затрагивает
- принцип «шаг конвейера идемпотентен по повтору» из `docs/architecture.md`:
шаг перестаёт быть неделимым;
- таблица фрагментов задачи: порядковый номер, границы по времени, состояние,
текст — и её миграция;
- состояния конвейера `created``converted``transcribe``done`;
- конвертер: деление записи на фрагменты по времени;
- адаптер SpeechKit: обращение на фрагмент вместо обращения на запись;
- раскладка `data/files`: файлы фрагментов рядом с исходным.
## Критерии приёмки
- Запись длиной шесть часов доходит до текста, и текст собран из фрагментов по
порядку без пропусков и повторов на стыках. Оракул — тест конвейера на
подставном распознавателе, отдающем свой текст на каждый фрагмент.
- Остановка процесса на середине не теряет распознанные фрагменты: после
перезапуска обращений по ним нет. Оракул — тест: половина фрагментов
отмечена, повторный проход шага зовёт распознаватель только по остатку.
- Отказ на одном фрагменте не отменяет готовые: задача остаётся пригодной к
повтору либо уходит в `failed` с сообщением пользователю. Оракул — тест с
распознавателем, отказывающим на третьем фрагменте.
- Долгая задача не останавливает короткие: голосовое на десять секунд проходит
конвейер, пока шестичасовая запись в работе. Оракул — тест захвата: воркер
берёт вторую задачу, пока первая держит фрагмент.
## Рамки
Длину фрагмента назначает ответ разведки `speechkit-limits` — до неё задача не
берётся. Раскладка `data/files` меняется, а это необратимо: формат согласуется с
человеком. Прогон на боевых ключах ради проверки запрещён — подставной
распознаватель.
+10 -5
View File
@@ -1,13 +1,16 @@
# 🎯 Запись длиной в несколько часов доходит до текста
# 🎯 Запись длиной до шести часов доходит до текста
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны.
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- **Теги:** decomposed
Лекция, созвон и интервью целиком превращаются в текст. Сегодня неизвестно даже,
на каком звене такая запись отваливается, — цель начинается с замера, а не с
переделки.
Лекция, созвон, интервью и диктофонная запись из семейного архива целиком
превращаются в текст. Сегодня неизвестно даже, на каком звене такая запись
отваливается, — цель начинается с замера, а не с переделки.
Расчётный потолок — **шесть часов**: он взят с запасом под диктофонные записи и
дорожки из видео, и замер проверяет, каким звеном он ограничен на самом деле.
## Завершение
@@ -21,3 +24,5 @@
Telegram, где предел сообщения — 4000 символов.
5. Долгая задача не блокирует короткие: запись на три часа не останавливает
конвейер для голосового на десять секунд.
6. Перезапуск сервиса на середине долгой расшифровки не начинает её заново:
работа продолжается с места остановки.
+39
View File
@@ -0,0 +1,39 @@
# ✨ Принимать до десяти файлов одной загрузкой
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
- **Теги:** goal:upload-reliability
Двигает пункт 2 «Завершения» цели: пачка файлов уходит одной загрузкой, и отказ
одного не отменяет остальные.
## Затрагивает
- контракт `POST /api/audio`: несколько файлов в одной форме и ответ списком;
- `TranscribeService.createTranscribeJob` — заведение нескольких задач одним
запросом;
- экран загрузки: выбор нескольких файлов и показ их состояний;
- предел числа файлов и предел размера запроса на стороне сервера;
- `docs/database.md` — предел числа файлов числом.
## Критерии приёмки
- Десять файлов одной формой заводят десять задач, и ответ отдаёт
идентификатор каждой. Оракул — тест приёма: в ответе десять записей, в
репозитории десять задач.
- Негодный файл в пачке отклоняется поимённо, а годные соседи заводятся. Оракул
— тест на пачке из годного и негодного: одна задача заведена, второй элемент
ответа несёт причину отказа.
- Одиннадцатый файл отклоняется до чтения содержимого, с названным пределом.
Оракул — тест на пачке из одиннадцати: задач не заведено, в теле ответа
предел числом.
- Прежний вызов с одним файлом работает по-старому. Оракул — существующие тесты
приёма по HTTP.
## Рамки
Публичный контракт `POST /api/audio` меняется, а это необратимо — форма ответа
согласуется с человеком. Загрузка частями сюда не входит: это
`chunked-upload-choice`. Приём из Telegram не трогается — бот присылает файлы по
одному.
+2
View File
@@ -19,3 +19,5 @@
ботом, видны ему же в браузере.
5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
выводится из учётной записи.
6. Скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
записи.
+6 -4
View File
@@ -5,9 +5,10 @@
- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- **Теги:** goal:ready-notification
Двигает все пять пунктов «Завершения» цели: готовый текст и отказ доходят до
пользователя веба без открытого приложения, адрес канала свой у каждого, а отказ
канала задачу не роняет.
Двигает пункты 1, 2, 4 и 5 «Завершения» цели: готовый текст и отказ доходят до
пользователя веба без открытого приложения, отказ канала задачу не роняет, а
пользователь Telegram получает ответ по-прежнему ботом. Выбор канала самим
пользователем (пункт 3) заводит `settings-screen`.
Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`;
источник `api` не получает ничего. Здесь появляется второй способ доставки, и
@@ -19,7 +20,8 @@
- `internal/service`, `completeJob` и `failJob` — выбор канала по источнику
задачи;
- новый адаптер поверх apprise либо прямого HTTP к ntfy;
- адрес канала у учётной записи: колонка и её миграция, экран настройки;
- адрес канала у учётной записи: колонка и её миграция (экран настройки заводит
`settings-screen`);
- секция конфигурации: адрес сервера ntfy, способ вызова apprise;
- `docs/architecture.md` — новая внешняя зависимость и чем она отказывает;
- `docs/security.md` — текст расшифровки уходит на внешний сервис;
+1 -1
View File
@@ -3,7 +3,7 @@
- **Тип:** fix
- **Категория:** Очередь
- **Зачем:** Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- **Теги:** review-2026-08-11
- **Теги:** review-2026-08-11, goal:upload-reliability
Приём пишет файл на диск, потом спрашивает у источника метаданных длительность,
потом заводит запись в учёте и задачу. Уборка при отказе есть **только на
+29
View File
@@ -0,0 +1,29 @@
# 🔬 Админка PocketBase: данные, пользователи и файлы
- **Тип:** research
- **Категория:** Очередь
- **Зачем:** Переход на PocketBase решён ради его панели администратора, а что она даёт по данным, пользователям и файлам на диске — не проверено.
Задача `pocketbase-storage` обоснована тремя доводами, и два из них ушли: учётные
записи заводит Authelia, а страницу статистики делает само приложение. Остался
довод про панель администратора — и он не проверен: что она показывает, правит
ли записи, видит ли пользователей, пришедших по OIDC, и что умеет с файлами на
диске.
## Вопрос
Что панель администратора PocketBase даёт по трём частям — правка записей задач
и файлов, список пользователей при входе через внешнего провайдера, работа с
файлами в хранилище, — и достаточно ли этого, чтобы держать перевод хранилища в
планах.
## Куда ляжет ответ
`docs/research/pocketbase.md` — перечнем по каждой части, с версией PocketBase, на
которой смотрели. Итог правит «зачем» задачи `pocketbase-storage` либо закрывает
её через `REJECTED.md`.
## Рамки
Смотрится на пустой локальной базе, боевые данные не участвуют. Ответ не решает,
чем становится очередь задач: это разведка `job-queue-choice`.
+6 -1
View File
@@ -2,13 +2,18 @@
- **Тип:** chore
- **Категория:** Очередь
- **Зачем:** Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего.
- **Зачем:** Из трёх доводов за перевод остался один: учётные записи ушли к Authelia, страницу статистики рисует само приложение, а панель администратора не проверена.
PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище,
учётные записи и панель администратора разом. Наблюдаемое поведение сервиса
после перевода не меняется: те же два входа, тот же конвейер, тот же текст на
выходе.
Из трёх доводов 2026-08-11 осталось полтора: учётные записи заводит Authelia по
OIDC, страницу статистики рисует само приложение. Довод про панель
администратора проверяет разведка `pocketbase-admin-fit`, и её ответ решает,
берётся эта задача или уходит в `REJECTED.md`.
Данные не переносим — база заводится с чистого листа, и это решение принято
сознательно.
+6 -4
View File
@@ -8,8 +8,10 @@
когда текст готов. Пользователь Telegram это уже имеет: бот отвечает сам.
Пользователь веба — нет.
Доставку берём внешнюю: apprise как отправитель, ntfy как канал. Web Push с
VAPID и собственным хранением подписок за целью **не стоит** — это отдельная
Каналов два: почта, адрес которой приходит вместе с входом через OIDC, и
Telegram для того, кто связал свою учётную запись с ботом. Доставку берём
внешнюю: apprise как отправитель, ntfy как один из каналов. Web Push с VAPID и
собственным хранением подписок за целью **не стоит** — это отдельная
инфраструктура ради того же результата.
## Завершение
@@ -18,8 +20,8 @@ VAPID и собственным хранением подписок за цел
приложения.
2. Отказ задачи доходит тем же путём и тем же человекочитаемым текстом, что
видит пользователь Telegram.
3. Адрес канала уведомлений задаёт пользователь, а не общий конфиг: у каждого
свой.
3. Канал и адрес уведомлений задаёт пользователь, а не общий конфиг: у каждого
свой, и от уведомлений можно отказаться.
4. Недоступность канала уведомлений не роняет задачу и не мешает ей завершиться:
текст остаётся в приложении.
5. Пользователь Telegram получает ответ по-прежнему ботом, а не вторым каналом.
+41
View File
@@ -0,0 +1,41 @@
# ✨ Сделать экран настроек и хранить настройки по пользователю
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
- **Теги:** goal:user-settings
Двигает пункты 1, 2 и 3 «Завершения» цели: у каждого пользователя свои
переключатели уровней текста и свой канал уведомлений, и они переживают
повторный вход.
Здесь появляется дом настроек — таблица и эндпоинты; применяют их задачи
уровней текста и доставки уведомлений.
## Затрагивает
- таблица настроек пользователя: переключатели уровней текста, канал и адрес
уведомлений, и её миграция;
- эндпоинты чтения и записи своих настроек;
- новый экран приложения;
- значения по умолчанию для пользователя, у которого настроек ещё нет;
- `docs/database.md` — представление данных и значения по умолчанию.
## Критерии приёмки
- Изменённая настройка видна после повторного входа и не меняется у соседа.
Оракул — тест эндпоинта на двух учётных записях: запись первой, чтение обеими.
- Пользователь без сохранённых настроек читает значения по умолчанию, а не
пустой ответ. Оракул — тест чтения для новой учётной записи.
- Чужие настройки не читаются и не пишутся по идентификатору. Оракул — тест
запроса к настройкам другого владельца: `404` либо `403`, содержимое не
отдаётся.
- Экран показывает переключатели уровней и поле канала, а сохранение доходит до
эндпоинта. Оракул — тест экрана на подставном API.
## Рамки
Применением настроек к конвейеру занимаются `llm-insights-adapter`,
`literary-text-level` и `ntfy-delivery` — здесь только дом настроек и экран.
Выпуск токенов API живёт на этом же экране, но заводит его `api-tokens`.
Берётся после `oidc-login`: до неё настройки не к кому привязать.
+22 -12
View File
@@ -4,22 +4,32 @@
- **Секция:** Направления
- **Зачем:** Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
К расшифровке добавляется короткий заголовок, пересказ и темы. Считает их
внешний сервис с OpenAI-совместимым интерфейсом — своей модели не держим, как не
держим и модели распознавания.
Текст записи выдаётся уровнями: сырая расшифровка, вычитанный литературный
текст, заголовок в одну строку, темы-теги и пересказ в два-три предложения.
Считает их внешний сервис с OpenAI-совместимым интерфейсом за шлюзом bifrost —
своей модели не держим, как не держим и модели распознавания.
**Это сдвиг границы паспорта.** До 2026-08-10 «понимание сказанного» было
записано как то, чем проект не является: «мы отдаём текст, а не выводы из него».
Граница сдвинута сознательно, и в паспорте от неё остался более узкий запрет —
ответов на вопросы по записи и поиска по смыслу не делаем.
Каждый уровень сверх сырого пользователь выключает у себя — это цель
[user-settings](user-settings.md).
**Это сдвиг границы паспорта, и сдвигали её дважды.** До 2026-08-10 «понимание
сказанного» было записано как то, чем проект не является: «мы отдаём текст, а не
выводы из него». 2026-08-11 к выводам добавился литературный текст — машинная
вычитка расшифровки, прежде запрещённая строкой про редактор.
От обоих запретов остались более узкие: ответов на вопросы по записи и поиска по
смыслу не делаем, правку текста руками человеку не даём.
## Завершение
1. У готовой записи есть заголовок в одну строку, и он виден в списке вместо
первых слов расшифровки.
2. У записи есть пересказ, который читается быстрее самой расшифровки.
3. У записи есть темы, и по ним список отбирается.
4. Отказ или молчание внешнего сервиса не роняют задачу: расшифровка доходит до
человека без заголовка и пересказа.
5. Стоимость обращения к сервису видна метрикой: сколько записей обработано и
2. Рядом с сырой расшифровкой лежит вычитанный текст: со знаками препинания, без
повторов и слов-паразитов.
3. У записи есть пересказ в два-три предложения, который читается быстрее самой
расшифровки.
4. У записи есть темы, и по ним список отбирается.
5. Отказ или молчание внешнего сервиса не роняют задачу: расшифровка доходит до
человека без выводов из неё.
6. Стоимость обращения к сервису видна метрикой: сколько записей обработано и
сколько это стоило по числу токенов.
+34
View File
@@ -0,0 +1,34 @@
# ✨ Показывать ход загрузки записи на экране
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
- **Теги:** goal:upload-reliability
Двигает пункт 3 «Завершения» цели: ход загрузки виден числом, а не одним
ожиданием.
Загрузка шестичасовой записи по сотовой сети идёт минутами. Пока сервер не
ответил, экран показывает долю отправленного и позволяет её отменить.
## Затрагивает
- экран загрузки: доля отправленного по каждому файлу пачки и отмена;
- отправка формы с отслеживанием хода вместо простого ожидания ответа;
- поведение при обрыве связи: что видит человек и что предлагается дальше.
## Критерии приёмки
- Доля отправленного растёт по ходу загрузки и доходит до конца. Оракул — тест
экрана на подставной отправке, отдающей события хода: показанные значения
растут и заканчиваются на сотне.
- Отмена прекращает загрузку, и задача на сервере не заводится. Оракул — тест:
после отмены запрос прерван, вызовов приёма нет.
- Обрыв связи показывается человекочитаемым текстом с предложением повторить, а
не молчанием. Оракул — тест на отправке, завершающейся ошибкой сети.
## Рамки
Докачки с места обрыва здесь нет — повтор начинает загрузку заново, а протокол
докачки разбирает `chunked-upload-choice`. Берётся после
`upload-and-status-screen`: экран заводит она.
+24
View File
@@ -0,0 +1,24 @@
# 🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
Человек отдаёт диктофонную запись или видео из семейного архива с телефона, по
сотовой сети, и загрузка либо доходит, либо честно говорит, что не дошла.
Отданное однажды второй раз не грузится и второй раз не распознаётся.
Дедупликация ищет совпадение **в пределах одного пользователя**: чужая
расшифровка не достаётся по совпадению хеш-суммы, даже когда файл тот же.
Загрузка частями за целью пока **не стоит**: сначала один запрос, а протокол
докачки разбирает разведка.
## Завершение
1. Файл, уже загруженный этим пользователем, узнаётся по хеш-сумме: сервис
возвращает прежнюю запись и не заводит вторую задачу.
2. До десяти файлов уходят одной загрузкой, и отказ одного не отменяет
остальные.
3. Ход загрузки виден на экране числом, а не одним ожиданием.
4. Оборванная загрузка не оставляет ни файла в хранилище, ни задачи в очереди.
+47
View File
@@ -0,0 +1,47 @@
# ✨ Считать объём, минуты и расход по каждому пользователю
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- **Теги:** goal:usage-stats
Двигает пункты 1, 2 и 4 «Завершения» цели: по каждому пользователю копятся
объём, минуты и расход на внешние сервисы, и повтор шага не удваивает счёт.
Учёт ведётся записями о потреблении, а не счётчиком в строке пользователя:
счётчик, увеличенный дважды при повторе шага, обратно не отматывается.
## Затрагивает
- таблица записей потребления: владелец, задача, вид ресурса, величина, время —
и её миграция;
- `TranscribeService.createTranscribeJob` — размер файла и длительность записи
на приёме;
- `TranscribeService.completeJob` и `failJob` — запись потребления после
успешного шага и после отказа;
- адаптер SpeechKit и адаптер языковой модели: откуда берутся минуты и токены;
- `internal/metrics` — счётчики того же расхода без разбивки по людям;
- `docs/database.md` — представление данных.
## Критерии приёмки
- Дошедшая до `done` запись оставляет строки потребления: объём файла,
длительность в минутах, минуты распознавания и токены языковой модели. Оракул
— тест конвейера на подставных адаптерах: четыре строки с владельцем задачи.
- Повторный проход шага после перезапуска не удваивает потребление. Оракул — тест:
шаг выполнен дважды, сумма по задаче не изменилась.
- Отказавшая задача сохраняет то, что уже потрачено, а не обнуляет счёт. Оракул
— тест на ветке `failJob` после успешной конвертации.
- Величина расхода не смешивает пользователей: выборка по владельцу отдаёт
только его строки. Оракул — тест на двух владельцах.
## Рамки
Снаружи эта задача не видна сама по себе: ни экрана, ни эндпоинта она не
заводит — их строит `admin-stats-screen`. Тип оставлен `feature` сознательно,
как шаг цели.
Потолков и отказов по исчерпании квоты не заводим — цель показывает, а не
ограничивает. Пересчёт расхода в деньги здесь не делается: копятся минуты и
токены, цена прайс-листа живёт вне сервиса. Данные о потреблении содержат
идентификаторы, но не текст записи и не имя файла (инвариант приватности).
+24
View File
@@ -0,0 +1,24 @@
# 🎯 Владелец видит, кто сколько загрузил и во что это обошлось
- **Тип:** goal
- **Секция:** Сопровождение
- **Зачем:** Распознавание и языковая модель оплачиваются по факту, а счёт приходит одной суммой: кто её набрал, из сервиса не выясняется.
Владелец открывает страницу и видит по каждому пользователю объём загруженного,
длительность записей в минутах и расход на внешние сервисы. Приглашая человека,
он понимает, во что это обойдётся.
Цель **показывает, но не ограничивает**: потолков и отказов по исчерпании квоты
здесь нет — перебравшего останавливает разговор или отзыв доступа в Authelia.
Секция — сопровождение, потому что наблюдает владелец сервиса, а не его
пользователь.
## Завершение
1. По каждому пользователю видны объём загруженных файлов и длительность записей
в минутах, накопительно и за период.
2. Видно, во что обошлись внешние сервисы: минуты распознавания и число токенов
языковой модели.
3. Страница открывается только владельцу, обычному пользователю она недоступна.
4. Учёт переживает перезапуск и не считает одну запись дважды при повторе шага.
+21
View File
@@ -0,0 +1,21 @@
# 🎯 Пользователь настраивает, что сервис делает с его записями
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
У каждого своя мера: одному нужна только сырая расшифровка, другому — все пять
уровней текста. Настройки принадлежат человеку, а не общему конфигу сервиса, и
переживают выход и повторный вход.
Выключенный уровень **не считается вовсе**: настройка экономит деньги, а не
прячет готовое.
## Завершение
1. Каждый уровень текста сверх сырого включается и выключается отдельно, и
выключенный не запрашивается у языковой модели.
2. Канал уведомлений выбирает сам пользователь, а не общий конфиг.
3. Настройки переживают выход и повторный вход, и у каждого пользователя свои.
4. Запись, заведённая до правки настроек, обрабатывается по той настройке,
которая действовала на приёме.
+3
View File
@@ -9,6 +9,9 @@
открывается с ярлыка, как обычное приложение. Конвейер обработки при этом
остаётся прежним — меняется вход и способ показать результат.
**Приложение — основной вход сервиса**, бот остаётся дополнением для голосовых
сообщений. Экраны рисуются сначала под телефон, потом под широкий экран.
Границы взяты уже: запись звука в самом приложении и работа без сети за целью
**не стоят** — файл выбирают в системном диалоге, а без сети приложение
показывает, что связи нет.