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). [security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет. из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
потолок проекта — шесть часов, и он взят с запасом, а не замером.
- **Приём большого файла.** Форма читается целиком, предел
`router.MaxMultipartMemory` — 32 МиБ, обрыв начинает загрузку заново.
Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет
публичный контракт приёма и потому идёт через решение в `adr/`.
- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом. сервис определяет содержимое сам, то ли часть записей теряется на этом.
@@ -149,7 +160,8 @@
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет. выкладке сегодня нет.
- **Выводы из текста.** Заголовок, пересказ и темы решено считать внешним - **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
сервисом с OpenAI-совместимым интерфейсом. Появляется пятая внешняя считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
зависимость, платная, и текст расшифровки начинает уходить ещё на одну Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
сторону — сдвиг периметра [security.md](security.md). уходить ещё на одну сторону — сдвиг периметра [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` судят, не перенесено ли понятие Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
через границу. через границу.
- **Редактор текста.** Расшифровку отдаём как есть; правка, разметка, экспорт в - **Правка текста руками.** Машинную вычитку расшифровки отдаём — литературный
форматы документов — не наша работа. текст стоит рядом с сырым, — а редактором не становимся: текст руками не
- **Хранилище записей.** Отдаём текст и на этом заканчиваем: библиотекой, архивом правим, не размечаем и не экспортируем в форматы документов. Граница сдвинута
и поиском по прошлым записям сервис не становится. Сколько запись лежит на 2026-08-11: до того запрет читался «расшифровку отдаём как есть».
диске после обработки — вопрос срока хранения, а его нет вовсе:
[database.md](database.md), «Представление данных».
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за - **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него». 2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
@@ -51,20 +64,39 @@
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем — граница цели файл. Своей записи и работы без сети не делаем — граница цели
[web-access](../tasks/items/web-access.md). [web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов, папками и общим доступом к чужим
записям сервис не становится.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв
доступа в Authelia.
## Типовые сценарии ## Типовые сценарии
1. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот Первые два — основные, и сегодня не работает ни один: приложения нет.
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
Загрузка показывает ход. Файл, который уже загружали, не грузится второй
раз. Когда текст готов, приходит уведомление; в списке запись видна
заголовком и темами.
2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
отвечает «обрабатываю», через минуту приходит текст ответом на то же отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями. несколькими частями. Работает сегодня.
2. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот 4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению. отличает их по MIME-типу и расширению. Работает сегодня.
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio`, получает идентификатор 5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
задачи и опрашивает `GET /api/status/:id`, пока не увидит `done` и текст. получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
4. **Отказ на середине.** Конвертация или распознавание не удались — задача увидит `done` и текст. Работает сегодня, но без токена и без разграничения
переходит в `failed`, а пользователь Telegram получает сообщение о том, что доступа.
именно не вышло, и предложение повторить. 6. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить.
## Референсы ## Референсы
+17 -4
View File
@@ -21,24 +21,37 @@
## Очередь ## Очередь
- [🐞 Не писать имя файла пользователя в журнал](items/no-user-filename-in-log.md) — Приём кладёт имя файла, данное пользователем, в журнал контейнера на каждой принятой записи — инвариант приватности объявляет это критическим и необратимым. - [🐞 Не писать имя файла пользователя в журнал](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 нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата. - [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
- [🔬 Админка PocketBase: данные, пользователи и файлы](items/pocketbase-admin-fit.md) — Переход на PocketBase решён ради его панели администратора, а что она даёт по данным, пользователям и файлам на диске — не проверено.
- [🔬 Очередь задач: своя таблица или готовая библиотека](items/job-queue-choice.md) — Очередь написана вручную: захват двумя запросами без транзакции, протухание временем, опрос раз в секунду вхолостую тремя воркерами. - [🔬 Очередь задач: своя таблица или готовая библиотека](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 открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. - [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать. - [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. - [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. - [✨ Свести приём и чтение записей к одному контракту для приложения](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-framework-choice.md) — Конвенция веб-UI написана под htmx, а решено делать SPA: до выбора фреймворка не заводится ни сборка, ни первый экран.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует. - [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. - [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-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/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.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) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается. - [🔬 Потолки 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» от «обработка отказала» не проверяет ничто. - [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем. - [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. - [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией. - [🧹 Переименовать образец конфига в 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/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/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
## Направления ## Направления
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил. - [🎯 Принимается запись любого формата, включая дорожку из видео](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/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
## Сопровождение ## Сопровождение
- [🎯 Состояние сервиса видно без чтения логов](items/service-observability.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, возвращается текстом. Программный вход: файл отдаётся формой, готовность и текст забираются опросом статуса задачи. - 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 - **Тип:** goal
- **Секция:** Направления - **Секция:** Направления
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны. - **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- **Теги:** decomposed - **Теги:** decomposed
Лекция, созвон и интервью целиком превращаются в текст. Сегодня неизвестно даже, Лекция, созвон, интервью и диктофонная запись из семейного архива целиком
на каком звене такая запись отваливается, — цель начинается с замера, а не с превращаются в текст. Сегодня неизвестно даже, на каком звене такая запись
переделки. отваливается, — цель начинается с замера, а не с переделки.
Расчётный потолок — **шесть часов**: он взят с запасом под диктофонные записи и
дорожки из видео, и замер проверяет, каким звеном он ограничен на самом деле.
## Завершение ## Завершение
@@ -21,3 +24,5 @@
Telegram, где предел сообщения — 4000 символов. Telegram, где предел сообщения — 4000 символов.
5. Долгая задача не блокирует короткие: запись на три часа не останавливает 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 перестаёт быть отдельным механизмом: право писать боту 5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
выводится из учётной записи. выводится из учётной записи.
6. Скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его
записи.
+6 -4
View File
@@ -5,9 +5,10 @@
- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана. - **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- **Теги:** goal:ready-notification - **Теги:** goal:ready-notification
Двигает все пять пунктов «Завершения» цели: готовый текст и отказ доходят до Двигает пункты 1, 2, 4 и 5 «Завершения» цели: готовый текст и отказ доходят до
пользователя веба без открытого приложения, адрес канала свой у каждого, а отказ пользователя веба без открытого приложения, отказ канала задачу не роняет, а
канала задачу не роняет. пользователь Telegram получает ответ по-прежнему ботом. Выбор канала самим
пользователем (пункт 3) заводит `settings-screen`.
Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`; Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`;
источник `api` не получает ничего. Здесь появляется второй способ доставки, и источник `api` не получает ничего. Здесь появляется второй способ доставки, и
@@ -19,7 +20,8 @@
- `internal/service`, `completeJob` и `failJob` — выбор канала по источнику - `internal/service`, `completeJob` и `failJob` — выбор канала по источнику
задачи; задачи;
- новый адаптер поверх apprise либо прямого HTTP к ntfy; - новый адаптер поверх apprise либо прямого HTTP к ntfy;
- адрес канала у учётной записи: колонка и её миграция, экран настройки; - адрес канала у учётной записи: колонка и её миграция (экран настройки заводит
`settings-screen`);
- секция конфигурации: адрес сервера ntfy, способ вызова apprise; - секция конфигурации: адрес сервера ntfy, способ вызова apprise;
- `docs/architecture.md` — новая внешняя зависимость и чем она отказывает; - `docs/architecture.md` — новая внешняя зависимость и чем она отказывает;
- `docs/security.md` — текст расшифровки уходит на внешний сервис; - `docs/security.md` — текст расшифровки уходит на внешний сервис;
+1 -1
View File
@@ -3,7 +3,7 @@
- **Тип:** fix - **Тип:** 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 - **Тип:** chore
- **Категория:** Очередь - **Категория:** Очередь
- **Зачем:** Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего. - **Зачем:** Из трёх доводов за перевод остался один: учётные записи ушли к Authelia, страницу статистики рисует само приложение, а панель администратора не проверена.
PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище, PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище,
учётные записи и панель администратора разом. Наблюдаемое поведение сервиса учётные записи и панель администратора разом. Наблюдаемое поведение сервиса
после перевода не меняется: те же два входа, тот же конвейер, тот же текст на после перевода не меняется: те же два входа, тот же конвейер, тот же текст на
выходе. выходе.
Из трёх доводов 2026-08-11 осталось полтора: учётные записи заводит Authelia по
OIDC, страницу статистики рисует само приложение. Довод про панель
администратора проверяет разведка `pocketbase-admin-fit`, и её ответ решает,
берётся эта задача или уходит в `REJECTED.md`.
Данные не переносим — база заводится с чистого листа, и это решение принято Данные не переносим — база заводится с чистого листа, и это решение принято
сознательно. сознательно.
+6 -4
View File
@@ -8,8 +8,10 @@
когда текст готов. Пользователь Telegram это уже имеет: бот отвечает сам. когда текст готов. Пользователь Telegram это уже имеет: бот отвечает сам.
Пользователь веба — нет. Пользователь веба — нет.
Доставку берём внешнюю: apprise как отправитель, ntfy как канал. Web Push с Каналов два: почта, адрес которой приходит вместе с входом через OIDC, и
VAPID и собственным хранением подписок за целью **не стоит** — это отдельная Telegram для того, кто связал свою учётную запись с ботом. Доставку берём
внешнюю: apprise как отправитель, ntfy как один из каналов. Web Push с VAPID и
собственным хранением подписок за целью **не стоит** — это отдельная
инфраструктура ради того же результата. инфраструктура ради того же результата.
## Завершение ## Завершение
@@ -18,8 +20,8 @@ VAPID и собственным хранением подписок за цел
приложения. приложения.
2. Отказ задачи доходит тем же путём и тем же человекочитаемым текстом, что 2. Отказ задачи доходит тем же путём и тем же человекочитаемым текстом, что
видит пользователь Telegram. видит пользователь Telegram.
3. Адрес канала уведомлений задаёт пользователь, а не общий конфиг: у каждого 3. Канал и адрес уведомлений задаёт пользователь, а не общий конфиг: у каждого
свой. свой, и от уведомлений можно отказаться.
4. Недоступность канала уведомлений не роняет задачу и не мешает ей завершиться: 4. Недоступность канала уведомлений не роняет задачу и не мешает ей завершиться:
текст остаётся в приложении. текст остаётся в приложении.
5. Пользователь Telegram получает ответ по-прежнему ботом, а не вторым каналом. 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. У готовой записи есть заголовок в одну строку, и он виден в списке вместо 1. У готовой записи есть заголовок в одну строку, и он виден в списке вместо
первых слов расшифровки. первых слов расшифровки.
2. У записи есть пересказ, который читается быстрее самой расшифровки. 2. Рядом с сырой расшифровкой лежит вычитанный текст: со знаками препинания, без
3. У записи есть темы, и по ним список отбирается. повторов и слов-паразитов.
4. Отказ или молчание внешнего сервиса не роняют задачу: расшифровка доходит до 3. У записи есть пересказ в два-три предложения, который читается быстрее самой
человека без заголовка и пересказа. расшифровки.
5. Стоимость обращения к сервису видна метрикой: сколько записей обработано и 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 @@
открывается с ярлыка, как обычное приложение. Конвейер обработки при этом открывается с ярлыка, как обычное приложение. Конвейер обработки при этом
остаётся прежним — меняется вход и способ показать результат. остаётся прежним — меняется вход и способ показать результат.
**Приложение — основной вход сервиса**, бот остаётся дополнением для голосовых
сообщений. Экраны рисуются сначала под телефон, потом под широкий экран.
Границы взяты уже: запись звука в самом приложении и работа без сети за целью Границы взяты уже: запись звука в самом приложении и работа без сети за целью
**не стоят** — файл выбирают в системном диалоге, а без сети приложение **не стоят** — файл выбирают в системном диалоге, а без сети приложение
показывает, что связи нет. показывает, что связи нет.