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
+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 @@
открывается с ярлыка, как обычное приложение. Конвейер обработки при этом
остаётся прежним — меняется вход и способ показать результат.
**Приложение — основной вход сервиса**, бот остаётся дополнением для голосовых
сообщений. Экраны рисуются сначала под телефон, потом под широкий экран.
Границы взяты уже: запись звука в самом приложении и работа без сети за целью
**не стоят** — файл выбирают в системном диалоге, а без сети приложение
показывает, что связи нет.