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