diff --git a/docs/architecture.md b/docs/architecture.md index 4ba3c7b..11ecaec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,7 +134,18 @@ текст расшифровки начинает уходить на сторону — сдвиг периметра [security.md](security.md). - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - из Telegram — точно, ограничения `deferred-general` по длине — нет. + из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный + потолок проекта — шесть часов, и он взят с запасом, а не замером. +- **Приём большого файла.** Форма читается целиком, предел + `router.MaxMultipartMemory` — 32 МиБ, обрыв начинает загрузку заново. + Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет + публичный контракт приёма и потому идёт через решение в `adr/`. +- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а + учёта по пользователям нет: метрики считают сервис целиком. Что именно + копится — записи о потреблении или счётчики — решает задача + `usage-accounting`. +- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт, + 2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается. - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. @@ -149,7 +160,8 @@ счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в выкладке сегодня нет. -- **Выводы из текста.** Заголовок, пересказ и темы решено считать внешним - сервисом с OpenAI-совместимым интерфейсом. Появляется пятая внешняя - зависимость, платная, и текст расшифровки начинает уходить ещё на одну - сторону — сдвиг периметра [security.md](security.md). +- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено + считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. + Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает + уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не + решено, отдельный это шаг конвейера или продолжение шага распознавания. diff --git a/docs/passport.md b/docs/passport.md index b06d6cc..712f84c 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -6,38 +6,51 @@ ## Цель -Превращать записанную речь в текст, который можно читать и искать. +Превращать записанную речь в текст, который можно читать и искать, и хранить +этот текст вместе с записью. + +**Сервис — архив, и это решено 2026-08-11.** Прежде граница читалась «отдаём +текст и на этом заканчиваем»; теперь расшифровки и исходные записи лежат +бессрочно, а список отбирается по темам. Причина в основном сценарии: семейный +архив загружают один раз, а возвращаются к нему годами. **Потребители** — список закрытый: он определяет, что считать нужным, а что интересным. | Кто | Что ему нужно от нас | | --- | --- | -| Владелец сервиса | Отправить голосовое сообщение из Telegram и получить текст ответом. Работает сегодня | -| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст. Приложение ставится на телефон; каждый видит только свои записи | -| Внешняя программа | Отдать файл по HTTP и опросить готовность. Работает сегодня, без разграничения доступа | +| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | +| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | +| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | +| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен | + +**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 +основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на +несколько часов через Telegram не проходит вовсе. Цель достигнута, когда: - запись любого распространённого формата принимается без предварительной подготовки, включая дорожку из видео; -- запись длиной в несколько часов доходит до текста, а не прерывается ошибкой при +- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при достижении предела; - сервисом пользуются несколько человек, и записи одного не видны другому; -- текст доступен там же, где загружали, — в приложении и в Telegram, и человек - узнаёт о его готовности, не держа приложение открытым. +- текст доступен там же, где загружали, — в приложении и в Telegram. Человек + узнаёт о его готовности, не держа приложение открытым; +- расшифровка не теряется: к записи возвращаются через месяц и находят её по + заголовку и темам; +- владелец видит расход по каждому пользователю и понимает, во что обходится + приглашение ещё одного человека. ## Что целью не является Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие через границу. -- **Редактор текста.** Расшифровку отдаём как есть; правка, разметка, экспорт в - форматы документов — не наша работа. -- **Хранилище записей.** Отдаём текст и на этом заканчиваем: библиотекой, архивом - и поиском по прошлым записям сервис не становится. Сколько запись лежит на - диске после обработки — вопрос срока хранения, а его нет вовсе: - [database.md](database.md), «Представление данных». +- **Правка текста руками.** Машинную вычитку расшифровки отдаём — литературный + текст стоит рядом с сырым, — а редактором не становимся: текст руками не + правим, не размечаем и не экспортируем в форматы документов. Граница сдвинута + 2026-08-11: до того запрет читался «расшифровку отдаём как есть». - **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута 2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него». @@ -51,20 +64,39 @@ - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый файл. Своей записи и работы без сети не делаем — граница цели [web-access](../tasks/items/web-access.md). +- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради + речи в них. Складом произвольных файлов, папками и общим доступом к чужим + записям сервис не становится. +- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и + показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем: + пользователя, потратившего слишком много, останавливает разговор или отзыв + доступа в Authelia. ## Типовые сценарии -1. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот +Первые два — основные, и сегодня не работает ни один: приложения нет. + +1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до + десяти записей разом — диктофонные дорожки и видео, — и закрывает его. + Загрузка показывает ход. Файл, который уже загружали, не грузится второй + раз. Когда текст готов, приходит уведомление; в списке запись видна + заголовком и темами. +2. **Возвращение к записи.** Через месяц человек открывает список, находит + запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую + расшифровку. +3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот отвечает «обрабатываю», через минуту приходит текст ответом на то же сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят - несколькими частями. -2. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот - отличает их по MIME-типу и расширению. -3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio`, получает идентификатор - задачи и опрашивает `GET /api/status/:id`, пока не увидит `done` и текст. -4. **Отказ на середине.** Конвертация или распознавание не удались — задача - переходит в `failed`, а пользователь Telegram получает сообщение о том, что - именно не вышло, и предложение повторить. + несколькими частями. Работает сегодня. +4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот + отличает их по MIME-типу и расширению. Работает сегодня. +5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, + получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не + увидит `done` и текст. Работает сегодня, но без токена и без разграничения + доступа. +6. **Отказ на середине.** Конвертация или распознавание не удались — задача + переходит в `failed`, а пользователь получает сообщение о том, что именно не + вышло, и предложение повторить. ## Референсы diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 9c2fdb9..9bcf28c 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -21,24 +21,37 @@ ## Очередь - [🐞 Не писать имя файла пользователя в журнал](items/no-user-filename-in-log.md) — Приём кладёт имя файла, данное пользователем, в журнал контейнера на каждой принятой записи — инвариант приватности объявляет это критическим и необратимым. -- [🧹 Сравнивать доменные ошибки через errors.As](items/errors-as-instead-of-typecast.md) — NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча. +- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. - [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата. +- [🔬 Админка PocketBase: данные, пользователи и файлы](items/pocketbase-admin-fit.md) — Переход на PocketBase решён ради его панели администратора, а что она даёт по данным, пользователям и файлам на диске — не проверено. - [🔬 Очередь задач: своя таблица или готовая библиотека](items/job-queue-choice.md) — Очередь написана вручную: захват двумя запросами без транзакции, протухание временем, опрос раз в секунду вхолостую тремя воркерами. -- [🧹 Перевести хранилище на встроенный PocketBase](items/pocketbase-storage.md) — Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего. +- [🧹 Перевести хранилище на встроенный PocketBase](items/pocketbase-storage.md) — Из трёх доводов за перевод остался один: учётные записи ушли к Authelia, страницу статистики рисует само приложение, а панель администратора не проверена. - [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. - [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать. - [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. - [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. +- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем. - [🔬 Выбор фреймворка для приложения](items/spa-framework-choice.md) — Конвенция веб-UI написана под htmx, а решено делать SPA: до выбора фреймворка не заводится ни сборка, ни первый экран. - [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует. - [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. - [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем. +- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе. +- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки. +- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей. - [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет. +- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса. +- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет. +- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет. - [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана. -- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами. +- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет. +- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего. +- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет. - [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается. -- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. +- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз. +- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново. +- [🧹 Сравнивать доменные ошибки через errors.As](items/errors-as-instead-of-typecast.md) — NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча. - [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто. - [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем. - [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. - [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией. +- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами. diff --git a/tasks/ROADMAP.md b/tasks/ROADMAP.md index 6089af8..a90e4f9 100644 --- a/tasks/ROADMAP.md +++ b/tasks/ROADMAP.md @@ -22,21 +22,24 @@ ## Запланировано -- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем. - [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки. +- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем. +- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги. +- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем. - [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться. ## Направления - [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил. -- [🎯 Запись длиной в несколько часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны. +- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново. - [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем. ## Сопровождение - [🎯 Состояние сервиса видно без чтения логов](items/service-observability.md) — Отказ замечает пользователь, а не владелец: оповещения нет, а путь записи по конвейеру собирается глазами по логам контейнера. +- [🎯 Владелец видит, кто сколько загрузил и во что это обошлось](items/usage-stats.md) — Распознавание и языковая модель оплачиваются по факту, а счёт приходит одной суммой: кто её набрал, из сервиса не выясняется. ## Готово -- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Основной вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой. +- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Первый вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой. - 2025-08-08 `api-transcription` — Запись, отданная по HTTP, возвращается текстом. Программный вход: файл отдаётся формой, готовность и текст забираются опросом статуса задачи. diff --git a/tasks/items/admin-stats-screen.md b/tasks/items/admin-stats-screen.md new file mode 100644 index 0000000..4c65851 --- /dev/null +++ b/tasks/items/admin-stats-screen.md @@ -0,0 +1,37 @@ +# ✨ Сделать страницу статистики для владельца + +- **Тип:** feature +- **Категория:** Очередь +- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет. +- **Теги:** goal:usage-stats + +Двигает пункты 1, 2 и 3 «Завершения» цели: расход по каждому пользователю виден +на странице, и открывается она только владельцу сервиса. + +## Затрагивает + +- новый экран приложения: таблица пользователей с объёмом, минутами и расходом, + накопительно и за период; +- эндпоинт сводки потребления с отбором по периоду; +- признак владельца сервиса: откуда он берётся — из группы OIDC или из + конфигурации; +- `docs/security.md` — второй уровень доступа помимо «свой или чужой». + +## Критерии приёмки + +- Владелец видит на странице строку по каждому пользователю с объёмом, минутами + и расходом. Оракул — тест экрана на подставном API: три пользователя, три + строки, числа совпадают с ответом. +- Обычный пользователь получает отказ и на странице, и на эндпоинте сводки, а не + пустую таблицу. Оракул — тест эндпоинта под обычной учётной записью: `403`. +- Отбор по периоду меняет числа. Оракул — тест на выборке за месяц и за всё + время. +- Страница не показывает ни имён файлов, ни текстов записей — только + идентификаторы и числа. Оракул — тест ответа сводки: полей с именем и текстом + в нём нет. + +## Рамки + +Берётся после `usage-accounting` — до неё показывать нечего. Управления +пользователями на странице не делаем: заводит и отключает их Authelia. Цен и +пересчёта в деньги здесь нет. diff --git a/tasks/items/api-tokens.md b/tasks/items/api-tokens.md new file mode 100644 index 0000000..65ae5a9 --- /dev/null +++ b/tasks/items/api-tokens.md @@ -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`: до неё представляться некому. diff --git a/tasks/items/chunked-upload-choice.md b/tasks/items/chunked-upload-choice.md new file mode 100644 index 0000000..f4dcd40 --- /dev/null +++ b/tasks/items/chunked-upload-choice.md @@ -0,0 +1,29 @@ +# 🔬 Загрузка большого файла частями + +- **Тип:** research +- **Категория:** Очередь +- **Зачем:** Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново. + +Шестичасовая диктофонная запись и видео из семейного архива весят гигабайты, а +уходят с телефона по сотовой сети. Сегодня приём читает форму целиком и держит +её в памяти до предела `router.MaxMultipartMemory`; что делать с обрывом, +неизвестно. + +Решено начать с одного запроса, а докачку разобрать отдельно — это она. + +## Вопрос + +Чем чинить обрыв загрузки большого файла: готовым протоколом докачки, своей +нарезкой на части поверх обычной формы или пределом размера с отказом, и во что +каждый вариант обходится на стороне приложения, сервера и обратного прокси. + +## Куда ляжет ответ + +`docs/research/upload.md` — вариантами с ценой каждого. Выбор оформляется +решением в `docs/adr/`, потому что меняет публичный контракт приёма. + +## Рамки + +Ответ учитывает обратный прокси перед сервисом: его предел размера тела и +таймаут — часть цены. Хеш-сумма на стороне приложения разбирается здесь же: +она решает, можно ли пропустить загрузку целиком. diff --git a/tasks/items/dedup-by-content-hash.md b/tasks/items/dedup-by-content-hash.md new file mode 100644 index 0000000..90a4938 --- /dev/null +++ b/tasks/items/dedup-by-content-hash.md @@ -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`). diff --git a/tasks/items/email-notification.md b/tasks/items/email-notification.md new file mode 100644 index 0000000..0adbb97 --- /dev/null +++ b/tasks/items/email-notification.md @@ -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, а +не у почты. diff --git a/tasks/items/literary-text-level.md b/tasks/items/literary-text-level.md new file mode 100644 index 0000000..f6eebb2 --- /dev/null +++ b/tasks/items/literary-text-level.md @@ -0,0 +1,38 @@ +# ✨ Отдавать вычитанный текст рядом с сырым + +- **Тип:** feature +- **Категория:** Очередь +- **Зачем:** Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет. +- **Теги:** goal:text-insights + +Двигает пункт «Завершения» цели про литературный текст: у записи появляется +второй уровень — тот же разговор, вычитанный до читаемого вида. + +Вычитку считает та же внешняя модель, что заголовок и темы. Сырой текст +остаётся и не переписывается: уровни лежат рядом, а не поверх друг друга. + +## Затрагивает + +- колонка литературного текста у задачи и её миграция; +- контракт чтения записи: поле рядом с сырым текстом; +- шаг выводов из текста: ещё одно обращение к модели; +- экран чтения записи: переключение между уровнями; +- ответ бота: какой из уровней уходит в Telegram. + +## Критерии приёмки + +- У дошедшей до `done` записи есть литературный текст, а сырой остался + нетронутым. Оракул — тест конвейера на подставной модели: оба поля заполнены, + сырое совпадает с ответом распознавания. +- Отказ модели на вычитке не роняет задачу и не портит сырой текст. Оракул — + тест с моделью, возвращающей ошибку: задача в `done`, сырой текст на месте. +- Выключенный в настройках уровень не запрашивается и остаётся пустым. Оракул — + тест с выключенной вычиткой: обращений за ней нет. +- Экран показывает оба уровня с переключением между ними. Оракул — тест экрана + на подставном API. + +## Рамки + +Ручной правки текста человеком не делаем — это граница паспорта: сервис отдаёт +машинную вычитку, а редактором не становится. Берётся после +`llm-insights-adapter`: клиент модели заводит она. diff --git a/tasks/items/llm-insights-adapter.md b/tasks/items/llm-insights-adapter.md new file mode 100644 index 0000000..fb2d3ea --- /dev/null +++ b/tasks/items/llm-insights-adapter.md @@ -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`. diff --git a/tasks/items/long-audio-chunking.md b/tasks/items/long-audio-chunking.md new file mode 100644 index 0000000..8f1b11a --- /dev/null +++ b/tasks/items/long-audio-chunking.md @@ -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` меняется, а это необратимо: формат согласуется с +человеком. Прогон на боевых ключах ради проверки запрещён — подставной +распознаватель. diff --git a/tasks/items/long-recordings.md b/tasks/items/long-recordings.md index d54cfcd..8427294 100644 --- a/tasks/items/long-recordings.md +++ b/tasks/items/long-recordings.md @@ -1,13 +1,16 @@ -# 🎯 Запись длиной в несколько часов доходит до текста +# 🎯 Запись длиной до шести часов доходит до текста - **Тип:** goal - **Секция:** Направления -- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны. +- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново. - **Теги:** decomposed -Лекция, созвон и интервью целиком превращаются в текст. Сегодня неизвестно даже, -на каком звене такая запись отваливается, — цель начинается с замера, а не с -переделки. +Лекция, созвон, интервью и диктофонная запись из семейного архива целиком +превращаются в текст. Сегодня неизвестно даже, на каком звене такая запись +отваливается, — цель начинается с замера, а не с переделки. + +Расчётный потолок — **шесть часов**: он взят с запасом под диктофонные записи и +дорожки из видео, и замер проверяет, каким звеном он ограничен на самом деле. ## Завершение @@ -21,3 +24,5 @@ Telegram, где предел сообщения — 4000 символов. 5. Долгая задача не блокирует короткие: запись на три часа не останавливает конвейер для голосового на десять секунд. +6. Перезапуск сервиса на середине долгой расшифровки не начинает её заново: + работа продолжается с места остановки. diff --git a/tasks/items/multi-file-upload.md b/tasks/items/multi-file-upload.md new file mode 100644 index 0000000..e687397 --- /dev/null +++ b/tasks/items/multi-file-upload.md @@ -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 не трогается — бот присылает файлы по +одному. diff --git a/tasks/items/multi-user.md b/tasks/items/multi-user.md index bd75adf..3726e48 100644 --- a/tasks/items/multi-user.md +++ b/tasks/items/multi-user.md @@ -19,3 +19,5 @@ ботом, видны ему же в браузере. 5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту выводится из учётной записи. +6. Скрипт ходит в API по токену, выпущенному пользователем, и видит ровно его + записи. diff --git a/tasks/items/ntfy-delivery.md b/tasks/items/ntfy-delivery.md index 8b7b92f..271c37d 100644 --- a/tasks/items/ntfy-delivery.md +++ b/tasks/items/ntfy-delivery.md @@ -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` — текст расшифровки уходит на внешний сервис; diff --git a/tasks/items/orphan-file-on-failed-intake.md b/tasks/items/orphan-file-on-failed-intake.md index 87b6c36..8f8a6d1 100644 --- a/tasks/items/orphan-file-on-failed-intake.md +++ b/tasks/items/orphan-file-on-failed-intake.md @@ -3,7 +3,7 @@ - **Тип:** fix - **Категория:** Очередь - **Зачем:** Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. -- **Теги:** review-2026-08-11 +- **Теги:** review-2026-08-11, goal:upload-reliability Приём пишет файл на диск, потом спрашивает у источника метаданных длительность, потом заводит запись в учёте и задачу. Уборка при отказе есть **только на diff --git a/tasks/items/pocketbase-admin-fit.md b/tasks/items/pocketbase-admin-fit.md new file mode 100644 index 0000000..3f356f2 --- /dev/null +++ b/tasks/items/pocketbase-admin-fit.md @@ -0,0 +1,29 @@ +# 🔬 Админка PocketBase: данные, пользователи и файлы + +- **Тип:** research +- **Категория:** Очередь +- **Зачем:** Переход на PocketBase решён ради его панели администратора, а что она даёт по данным, пользователям и файлам на диске — не проверено. + +Задача `pocketbase-storage` обоснована тремя доводами, и два из них ушли: учётные +записи заводит Authelia, а страницу статистики делает само приложение. Остался +довод про панель администратора — и он не проверен: что она показывает, правит +ли записи, видит ли пользователей, пришедших по OIDC, и что умеет с файлами на +диске. + +## Вопрос + +Что панель администратора PocketBase даёт по трём частям — правка записей задач +и файлов, список пользователей при входе через внешнего провайдера, работа с +файлами в хранилище, — и достаточно ли этого, чтобы держать перевод хранилища в +планах. + +## Куда ляжет ответ + +`docs/research/pocketbase.md` — перечнем по каждой части, с версией PocketBase, на +которой смотрели. Итог правит «зачем» задачи `pocketbase-storage` либо закрывает +её через `REJECTED.md`. + +## Рамки + +Смотрится на пустой локальной базе, боевые данные не участвуют. Ответ не решает, +чем становится очередь задач: это разведка `job-queue-choice`. diff --git a/tasks/items/pocketbase-storage.md b/tasks/items/pocketbase-storage.md index 0fe7974..13558be 100644 --- a/tasks/items/pocketbase-storage.md +++ b/tasks/items/pocketbase-storage.md @@ -2,13 +2,18 @@ - **Тип:** chore - **Категория:** Очередь -- **Зачем:** Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего. +- **Зачем:** Из трёх доводов за перевод остался один: учётные записи ушли к Authelia, страницу статистики рисует само приложение, а панель администратора не проверена. PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище, учётные записи и панель администратора разом. Наблюдаемое поведение сервиса после перевода не меняется: те же два входа, тот же конвейер, тот же текст на выходе. +Из трёх доводов 2026-08-11 осталось полтора: учётные записи заводит Authelia по +OIDC, страницу статистики рисует само приложение. Довод про панель +администратора проверяет разведка `pocketbase-admin-fit`, и её ответ решает, +берётся эта задача или уходит в `REJECTED.md`. + Данные не переносим — база заводится с чистого листа, и это решение принято сознательно. diff --git a/tasks/items/ready-notification.md b/tasks/items/ready-notification.md index 34d8751..b190e35 100644 --- a/tasks/items/ready-notification.md +++ b/tasks/items/ready-notification.md @@ -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 получает ответ по-прежнему ботом, а не вторым каналом. diff --git a/tasks/items/settings-screen.md b/tasks/items/settings-screen.md new file mode 100644 index 0000000..98eec62 --- /dev/null +++ b/tasks/items/settings-screen.md @@ -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`: до неё настройки не к кому привязать. diff --git a/tasks/items/text-insights.md b/tasks/items/text-insights.md index 1ec8d71..55f136e 100644 --- a/tasks/items/text-insights.md +++ b/tasks/items/text-insights.md @@ -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. Стоимость обращения к сервису видна метрикой: сколько записей обработано и сколько это стоило по числу токенов. diff --git a/tasks/items/upload-progress.md b/tasks/items/upload-progress.md new file mode 100644 index 0000000..e7522a5 --- /dev/null +++ b/tasks/items/upload-progress.md @@ -0,0 +1,34 @@ +# ✨ Показывать ход загрузки записи на экране + +- **Тип:** feature +- **Категория:** Очередь +- **Зачем:** Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей. +- **Теги:** goal:upload-reliability + +Двигает пункт 3 «Завершения» цели: ход загрузки виден числом, а не одним +ожиданием. + +Загрузка шестичасовой записи по сотовой сети идёт минутами. Пока сервер не +ответил, экран показывает долю отправленного и позволяет её отменить. + +## Затрагивает + +- экран загрузки: доля отправленного по каждому файлу пачки и отмена; +- отправка формы с отслеживанием хода вместо простого ожидания ответа; +- поведение при обрыве связи: что видит человек и что предлагается дальше. + +## Критерии приёмки + +- Доля отправленного растёт по ходу загрузки и доходит до конца. Оракул — тест + экрана на подставной отправке, отдающей события хода: показанные значения + растут и заканчиваются на сотне. +- Отмена прекращает загрузку, и задача на сервере не заводится. Оракул — тест: + после отмены запрос прерван, вызовов приёма нет. +- Обрыв связи показывается человекочитаемым текстом с предложением повторить, а + не молчанием. Оракул — тест на отправке, завершающейся ошибкой сети. + +## Рамки + +Докачки с места обрыва здесь нет — повтор начинает загрузку заново, а протокол +докачки разбирает `chunked-upload-choice`. Берётся после +`upload-and-status-screen`: экран заводит она. diff --git a/tasks/items/upload-reliability.md b/tasks/items/upload-reliability.md new file mode 100644 index 0000000..3315712 --- /dev/null +++ b/tasks/items/upload-reliability.md @@ -0,0 +1,24 @@ +# 🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую + +- **Тип:** goal +- **Секция:** Запланировано +- **Зачем:** Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги. + +Человек отдаёт диктофонную запись или видео из семейного архива с телефона, по +сотовой сети, и загрузка либо доходит, либо честно говорит, что не дошла. +Отданное однажды второй раз не грузится и второй раз не распознаётся. + +Дедупликация ищет совпадение **в пределах одного пользователя**: чужая +расшифровка не достаётся по совпадению хеш-суммы, даже когда файл тот же. + +Загрузка частями за целью пока **не стоит**: сначала один запрос, а протокол +докачки разбирает разведка. + +## Завершение + +1. Файл, уже загруженный этим пользователем, узнаётся по хеш-сумме: сервис + возвращает прежнюю запись и не заводит вторую задачу. +2. До десяти файлов уходят одной загрузкой, и отказ одного не отменяет + остальные. +3. Ход загрузки виден на экране числом, а не одним ожиданием. +4. Оборванная загрузка не оставляет ни файла в хранилище, ни задачи в очереди. diff --git a/tasks/items/usage-accounting.md b/tasks/items/usage-accounting.md new file mode 100644 index 0000000..83666e6 --- /dev/null +++ b/tasks/items/usage-accounting.md @@ -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` сознательно, +как шаг цели. + +Потолков и отказов по исчерпании квоты не заводим — цель показывает, а не +ограничивает. Пересчёт расхода в деньги здесь не делается: копятся минуты и +токены, цена прайс-листа живёт вне сервиса. Данные о потреблении содержат +идентификаторы, но не текст записи и не имя файла (инвариант приватности). diff --git a/tasks/items/usage-stats.md b/tasks/items/usage-stats.md new file mode 100644 index 0000000..1c6f1d6 --- /dev/null +++ b/tasks/items/usage-stats.md @@ -0,0 +1,24 @@ +# 🎯 Владелец видит, кто сколько загрузил и во что это обошлось + +- **Тип:** goal +- **Секция:** Сопровождение +- **Зачем:** Распознавание и языковая модель оплачиваются по факту, а счёт приходит одной суммой: кто её набрал, из сервиса не выясняется. + +Владелец открывает страницу и видит по каждому пользователю объём загруженного, +длительность записей в минутах и расход на внешние сервисы. Приглашая человека, +он понимает, во что это обойдётся. + +Цель **показывает, но не ограничивает**: потолков и отказов по исчерпании квоты +здесь нет — перебравшего останавливает разговор или отзыв доступа в Authelia. + +Секция — сопровождение, потому что наблюдает владелец сервиса, а не его +пользователь. + +## Завершение + +1. По каждому пользователю видны объём загруженных файлов и длительность записей + в минутах, накопительно и за период. +2. Видно, во что обошлись внешние сервисы: минуты распознавания и число токенов + языковой модели. +3. Страница открывается только владельцу, обычному пользователю она недоступна. +4. Учёт переживает перезапуск и не считает одну запись дважды при повторе шага. diff --git a/tasks/items/user-settings.md b/tasks/items/user-settings.md new file mode 100644 index 0000000..e425f3d --- /dev/null +++ b/tasks/items/user-settings.md @@ -0,0 +1,21 @@ +# 🎯 Пользователь настраивает, что сервис делает с его записями + +- **Тип:** goal +- **Секция:** Запланировано +- **Зачем:** Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем. + +У каждого своя мера: одному нужна только сырая расшифровка, другому — все пять +уровней текста. Настройки принадлежат человеку, а не общему конфигу сервиса, и +переживают выход и повторный вход. + +Выключенный уровень **не считается вовсе**: настройка экономит деньги, а не +прячет готовое. + +## Завершение + +1. Каждый уровень текста сверх сырого включается и выключается отдельно, и + выключенный не запрашивается у языковой модели. +2. Канал уведомлений выбирает сам пользователь, а не общий конфиг. +3. Настройки переживают выход и повторный вход, и у каждого пользователя свои. +4. Запись, заведённая до правки настроек, обрабатывается по той настройке, + которая действовала на приёме. diff --git a/tasks/items/web-access.md b/tasks/items/web-access.md index 7dab2d1..e8f0af1 100644 --- a/tasks/items/web-access.md +++ b/tasks/items/web-access.md @@ -9,6 +9,9 @@ открывается с ярлыка, как обычное приложение. Конвейер обработки при этом остаётся прежним — меняется вход и способ показать результат. +**Приложение — основной вход сервиса**, бот остаётся дополнением для голосовых +сообщений. Экраны рисуются сначала под телефон, потом под широкий экран. + Границы взяты уже: запись звука в самом приложении и работа без сети за целью **не стоят** — файл выбирают в системном диалоге, а без сети приложение показывает, что связи нет.