Compare commits

..
31 Commits
Author SHA1 Message Date
av aa20b229f9 tasks: заведена задача про прослушивание записи в приложении
- play-recording-in-app: экран, привязка проигрываемой копии к задаче,
  отдача файла хранилищем
- цели web-access добавлен седьмой пункт «Завершения»
2026-08-12 09:14:05 +03:00
av f48110df5a tasks: обновление Go и сверка версии собраны в одну задачу
- go-1-26-upgrade вобрал критерии gate-go-version-sync: версия правится и
  сверяется одним заходом
- gate-go-version-sync ушла в REJECTED с причиной
2026-08-12 09:09:12 +03:00
av 07433bb9f9 заведены задачи из урожая ревью и заметок владельца 2026-08-12 09:01:31 +03:00
av 5f1273292a закрыта задача pocketbase-storage 2026-08-12 08:32:25 +03:00
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00
av 09cedc4e61 закрыта задача errors-as-instead-of-typecast 2026-08-11 18:05:07 +03:00
av 2559d09fc8 доменные ошибки сравниваются через errors.As, отказ Close не теряется
- признаки «работы нет» и «задача не найдена» узнаются по смыслу, а не
  приведением типа: обёртка `%w` на пути больше не превращает пустой прогон
  воркера в отказ раз в секунду
- отказ закрытия соединения с распознавателем доходит до вызывающего
  (`errors.Join`) либо до журнала; у `errcheck` включён `check-blank`, иначе
  критерий принимал реализацию, выбрасывающую отказ в пустоту
- заведены первые тесты пакета worker и capability `pipeline`; долг из четырёх
  замечаний линтера закрыт, гейт зелёный целиком
2026-08-11 18:04:25 +03:00
av b7dd060ba0 CLAUDE.md: в инвариант приватности дописано изъятие про расширение
- хвост после последней точки попадает в журнал внутри пути файла и остаётся
  там осознанно; наружу он выходит только приведённым к перечню
- в модели угроз это перестало читаться незакрытым остатком
2026-08-11 16:42:10 +03:00
av 614771139a закрыта задача no-user-filename-in-log 2026-08-11 16:38:17 +03:00
av bd6001cd8d имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
2026-08-11 16:37:58 +03:00
av ffa36e96c7 закрыта задача spa-framework-choice 2026-08-11 14:52:04 +03:00
av 54ca4c0e50 docs: фреймворком приложения выбран Vue 3 с роутером 5 и сборкой Vite
- заведены записка разведки docs/research/spa-framework.md со сравнением Svelte,
  Vue и React на одном экране и решение ADR-2026-08-11-spa-on-vue
- docs/conventions/web-ui.md переписан под Vue: компоненты, маршруты, состояние,
  обращение к API и показ ошибок
- закрыт вопрос «Приложение» в docs/architecture.md, уточнена задача spa-skeleton
2026-08-11 14:51:55 +03:00
av f1524fefd8 закрыта задача job-queue-choice 2026-08-11 14:13:41 +03:00
av df65eb5e32 docs: решено оставить очередь своей таблицей коллекцией PocketBase
- заведены записка разведки job-queue-choice и ADR: готовые библиотеки River и
  goqite отвергнуты, захват сворачивается в один запрос с RETURNING
- в architecture.md уточнён принцип «очередь таблицей» и закрыт открытый вопрос
  «Очередь», кроме холостого опроса
- задача pocketbase-storage забрала очередь себе: границы, счётчик попыток,
  состояние «мертва» и оракулы
2026-08-11 14:13:27 +03:00
av 6c996209b7 docs: применены находки вычитки по разведке PocketBase
- сняты повтор слова в записке разведки и та же строка в цитате ADR,
  убран термин «чекпоинт», разведена цепочка местоимений в паспорте
- oidc-login: «зачем» перестало повторять тело, задача заявила пункт 3
  «Завершения» цели multi-user — он не был закрыт ни одной её задачей
2026-08-11 13:17:28 +03:00
av 9b26c6aab1 закрыта задача pocketbase-admin-fit 2026-08-11 13:03:57 +03:00
av 10ffe8bec3 docs: решено перевести хранилище и файлы записей на PocketBase
- разведка pocketbase-admin-fit ответила замером панели версии 0.39.10:
  записка в docs/research/pocketbase.md, решение — в ADR
- панель показывает файлы и пользователей только своих, поэтому файлы
  переезжают в её раскладку, а вход идёт через её провайдера OIDC
- периметр расширился панелью на /_/ и паролем суперпользователя;
  закрывает её Authelia на прокси, задачи в беклоге у этого нет
2026-08-11 13:03:38 +03:00
av ba7b4f37a6 docs: устранены расхождения документов между собой и с кодом
- README разгружен: контракт HTTP API, таблицы БД, состояния задач и белый
  список отданы нормативным источникам ссылками
- в `architecture.md` и `review.md` числа и механика захвата задачи заменены
  ссылками на `database.md`, а описание конвейера ревью — на прогон от 2026-08-11
- в `CLAUDE.md` и `security.md` поправлены границы домена, оценка объёма записи
  и адрес очереди задач
2026-08-11 12:24:41 +03:00
av 2161d7f38e docs: канон документов поднят с версии 12 на 14
- `docs/.pm.json` переименован в `docs/.docs.json`, версия канона — 14
- ADR принимает записку разведки как источник решения наравне с архивным
  `design.md`
- заведён `tasks/.tasks.json`, а в описании гейта — шаги `tasks.py check` и
  `openspec.py check`
2026-08-11 12:24:27 +03:00
av faa1d7c699 tasks: заведена цель про изъятие записи из архива и задача удаления
- data-ownership — обратное право к бессрочному хранению: человек убирает свою
  запись вместе с файлом, объектом в Object Storage и всеми уровнями текста;
- delete-record закрывает все пять пунктов цели: подтверждение, необратимость,
  чужую запись не тронуть, повторная загрузка того же файла заводит новую
  задачу, а строки потребления остаются — деньги потрачены;
- docs/security.md: пункт «Удаление данных по требованию» перестал говорить,
  что задачи под это нет.
2026-08-11 10:54:43 +03:00
av a5884f1fcd security: модель угроз обновлена под расширившийся периметр
- заведён раздел «Куда уходит содержимое записи»: к Object Storage, SpeechKit и
  Telegram добавляются языковая модель за bifrost, канал уведомлений и почта;
- целевое разграничение доступа описано четырьмя механизмами вместо белого
  списка: сессия OIDC, владелец записи, личный токен, признак владельца
  сервиса; токен — первый секрет, который живёт в базе, а не в конфиге;
- исправлено неверное утверждение про логи: имя файла отправителя пишется
  строкой transcribe.go:107, чинит это задача no-user-filename-in-log;
- бессрочное хранение и отсутствие квот записаны в «Что вне модели» как
  следствие решения паспорта, а не как недосмотр.
2026-08-11 10:39:08 +03:00
av 9a54afba60 tasks: заведены три задачи под незакрытые пункты цели о долгих записях
- intake-limits-measure меряет четыре звена, которых не берёт speechkit-limits:
  приём из Telegram и по HTTP, конвертацию и заливку в Object Storage;
- reject-oversized-recording отклоняет запись сверх потолка на приёме,
  long-text-delivery отдаёт текст в сотни килобайт файлом вместо сотни
  сообщений Telegram;
- все шесть пунктов «Завершения» цели теперь закрыты задачами.
2026-08-11 10:28:56 +03:00
av d2c85af80a tasks: целевая картина пересобрана — три цели, тринадцать задач, сдвинутые границы
- паспорт: сервис объявлен архивом с бессрочным хранением записей и текстов,
  машинная вычитка расшифровки внутри границ, приложение — основной вход;
  добавлены две границы: не файловое хранилище общего назначения и не биллинг;
- заведены цели upload-reliability, user-settings, usage-stats и тринадцать
  задач; очередь пересобрана — сперва починки, затем разведки о хранилище,
  затем доступ и владелец, и только потом экраны;
- архитектура: четыре новых открытых вопроса — приём большого файла, учёт
  расхода, срок хранения, потолок шести часов.
2026-08-11 10:05:22 +03:00
av 2333e80633 tasks: заведены шесть задач из урожая ревью и переименования образца конфига
- пять из урожая change 2026-08-11-fix-http-handler-tests, тег review-2026-08-11;
  утечка имени файла в журнал поставлена первой строкой очереди
- опечатка transcibe дописана в json-api-for-spa: текст ошибки — часть
  необратимого контракта, и в одиночку он не правится
2026-08-11 08:59:45 +03:00
av 1bd6d03188 закрыта задача http-handler-tests-never-green 2026-08-11 08:35:37 +03:00
av 6c04c801c9 http: тесты приёма переписаны на подставные адаптеры
- проверки больше не зовут ffprobe и не меняют рабочий каталог процесса;
  добавлены случаи на отказ чтения метаданных и на отсутствие поля audio
- заведена спека intake на приём по HTTP, ADR о подставных адаптерах,
  запись в журнал ревью о проверке, которая не могла упасть
- go test снят из объявленных долгов CLAUDE.md, послабление errcheck
  для _test.go в .golangci.yml убрано
2026-08-11 08:35:11 +03:00
av bb973b0a68 Пересмотр очереди, выводы из текста, наблюдаемость
Разведка job-queue-choice — очередь написана вручную: захват не
транзакционен, повторов и счётчика попыток нет, очереди мёртвых задач
нет. Стоит перед сменой хранилища, чтобы не переписывать захват дважды.

Цель text-insights: заголовок, пересказ и темы внешним сервисом с
OpenAI-совместимым интерфейсом. Граница паспорта сдвинута — «понимание
сказанного» было записано как то, чем проект не является; за границей
остались ответы на вопросы по записи и поиск по смыслу.

Цель service-observability в сопровождении и разведка opentelemetry-fit:
/metrics остаётся и развивается, способ решает замер.
2026-08-10 21:46:08 +03:00
av d21da8575c Единый список задач вместо двух секций, порядок по выполнению
Секции «Ядро» и «Инфра» слиты в одну «Очередь»: полок домена у проекта
нет, а две секции держали два независимых порядка вместо одного.

Порядок: красный гейт, потом долги, задевающие интерфейсы, потом
хранилище, вход, разграничение, приложение. Разведка потолков SpeechKit
последней — она обслуживает направление, а не очередь.
2026-08-10 21:39:55 +03:00
av 115b3796e8 Нарезка задач под целевое состояние: PWA, вход, хранилище
Роадмап: web-access переименована под приложение, которое ставится на
телефон; заведена цель ready-notification — уведомление о готовности
без открытого приложения.

Беклог: десять задач. Многопользовательская цепочка (oidc-login,
record-ownership, telegram-account-link), веб (json-api-for-spa,
spa-skeleton, upload-and-status-screen, records-list-screen,
installable-pwa), уведомления через apprise и ntfy, разведка выбора
фреймворка. Очередь: долги, хранилище, вход, приложение.

Решение сменилось с htmx на SPA, поэтому conventions/web-ui.md снята
целиком и оставлена честной строкой до итога разведки. Открытые вопросы
архитектуры, границы паспорта и триггеры метки ревью приведены в
соответствие.
2026-08-10 21:36:54 +03:00
av 4d1c2bf44c Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
2026-08-10 21:19:07 +03:00
av a4646c0930 Инструкции для Claude, конфиг линтера, актуализация README
CLAUDE.md описывает конвейер задач, подвохи конфига и правило про четыре
списка колонок в репозитории sqlite.

.golangci.yml включает стандартный набор плюс errorlint; осознанные
непроверенные вызовы вынесены в исключения.

README приведён к коду: эндпоинты /api/audio и /api/status/:id, состояния
задач, структура internal/, фактические колонки таблиц.
2026-08-10 20:24:30 +03:00
166 changed files with 15833 additions and 1220 deletions
+155
View File
@@ -0,0 +1,155 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+160
View File
@@ -0,0 +1,160 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+174
View File
@@ -0,0 +1,174 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+109
View File
@@ -0,0 +1,109 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+143
View File
@@ -0,0 +1,143 @@
---
name: "OPSX: Sync"
description: Sync delta specs from a change to main specs
category: Workflow
tags: [workflow, specs, experimental]
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
@@ -0,0 +1,159 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,117 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+289
View File
@@ -0,0 +1,289 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+113
View File
@@ -0,0 +1,113 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+147
View File
@@ -0,0 +1,147 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
+25
View File
@@ -0,0 +1,25 @@
version: "2"
linters:
default: standard
enable:
- errorlint
settings:
errcheck:
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
# теряется молча» принимается реализацией, которая его теряет. Отказ,
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
check-blank: true
exclude-functions:
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
- (io.Closer).Close
- (*database/sql.DB).Close
- (*os.File).Close
- (io.ReadCloser).Close
- os.Remove
# Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
formatters:
enable:
- gofmt
+172
View File
@@ -0,0 +1,172 @@
# CLAUDE.md
Памятка для работы над transcriber. Перед задачей прочитай также
[docs/passport.md](docs/passport.md),
[docs/architecture.md](docs/architecture.md) и
[docs/conventions/](docs/conventions/README.md).
Проект ведём по-русски.
## Что это
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
панель администратора.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не
заводит, с живым потоком не работает и складом произвольных файлов не служит.
Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив.
Перечень выше — выжимка, границу домена целиком держит
[docs/passport.md](docs/passport.md).
## Стек
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Инварианты
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
всех местах выкладки. **critical**
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**critical**
*Изъятие:* расширение — хвост после последней точки — в журнал попадает
собственным полем: по нему прослеживается путь записи. Имени файла в журнале
нет вовсе (инвариант ниже). Изъятие узкое
и кончается журналом: наружу, меткой метрики, расширение выходит только
приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
остаток — [docs/security.md](docs/security.md).
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает.
**major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом шага. Необратимо: хранилище считает применённое по имени файла.
**critical**
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
поле теряется **только у задачи, попавшей к воркеру**. **major**
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
достался другому, завершается без записи и без ответа отправителю. Иначе два
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
**major**
## Команды
```bash
go build ./... # CGO не нужен
go test ./...
go vet ./...
gofmt -l .
golangci-lint run
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
```
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в
[docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*».
## Гейт
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`.
- **Где логи шагов:** вывод команды, отдельного файла нет.
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
`docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий:
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта,
каталог не найден), 4 внутренний сбой.
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов,
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
именем недостающего плагина, а не пропускается молча.
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл `av-dev-docs:healthcheck`, и звать его надо руками;
- покрытие изменённых строк не считается ничем.
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
Списывать отказ на долг больше нельзя: списывать не на что.
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
новый:
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
сравнения ошибок приведением типа. Закрыто задачей
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
настройка `check-blank`, поэтому `_ = x.Close()` больше не снимает замечание:
отказ, который решено не проверять, объявляют в `exclude-functions` поимённо;
- `go test ./...` чинила задача `http-handler-tests-never-green`.
## Запреты
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/data.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
писать: этот каталог смонтирован на сервере.
## Работа
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли
задачами.
- **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо.
## Язык
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский.
+4 -7
View File
@@ -1,11 +1,8 @@
# Build stage # Build stage
FROM docker.io/library/golang:1.24-alpine AS build-env FROM docker.io/library/golang:1.25-alpine AS build-env
# Install build dependencies # Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
RUN apk --no-cache add \ # и CGO больше не требуется.
build-base \
sqlite-dev \
&& rm -rf /var/cache/apk/*
# Set up the working directory # Set up the working directory
WORKDIR /app WORKDIR /app
@@ -20,7 +17,7 @@ RUN go mod download
COPY . . COPY . .
# Build the application # Build the application
RUN go build -o transcriber . RUN CGO_ENABLED=0 go build -o transcriber .
# ---------------- # ----------------
# Production stage # Production stage
+75 -106
View File
@@ -1,22 +1,25 @@
# Transcriber Service # Transcriber Service
Сервис для расшифровки аудиозаписей с REST API. Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API.
## Возможности ## Возможности
- Загрузка аудиофайлов любого формата - Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
- Автоматическая генерация UUID для файлов - Приём аудиофайлов через HTTP API
- Сохранение файлов на диск - Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit
- Отслеживание статуса задач расшифровки - Отслеживание статуса задач расшифровки
- SQLite база данных для хранения метаданных - Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus
## Технологии ## Технологии
- **Веб-фреймворк**: gin-gonic/gin - **Веб-фреймворк**: gin-gonic/gin
- **SQL Builder**: doug-martin/goqu - **Telegram**: go-telegram-bot-api
- **Миграции БД**: pressly/goose - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **База данных**: SQLite - **Конвертация**: ffmpeg
- **UUID**: google/uuid - **Хранилище, файлы и панель**: встроенная PocketBase
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен)
- **Метрики**: prometheus/client_golang
## Установка и запуск ## Установка и запуск
@@ -25,12 +28,24 @@
```bash ```bash
go mod tidy go mod tidy
``` ```
3. Запустите приложение: 3. Скопируйте образец конфига и заполните его:
```bash ```bash
go run main.go cp config.dist.toml config.toml
```
4. Запустите приложение:
```bash
go run . -c config.toml
``` ```
Сервер запустится на порту 8080. Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
установленный `ffmpeg`.
### Белый список Telegram
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»;
известные прорехи образца конфига, включая недостающий ключ белого списка, —
[docs/conventions/config.md](docs/conventions/config.md).
## Деплой ## Деплой
@@ -44,115 +59,69 @@ inv pl -- transcriber
локально и едет на сервер через `docker save`/`load`, реестр не участвует. локально и едет на сервер через `docker save`/`load`, реестр не участвует.
Локально образ можно собрать и руками — `task image` даст `transcriber:dev`. Локально образ можно собрать и руками — `task image` даст `transcriber:dev`.
## API Endpoints ## HTTP API
### POST /api/transcribe Четыре маршрута: `POST /api/audio` — приём записи, `GET /api/status/:id` —
готовность задачи, `GET /metrics` — метрики Prometheus с префиксом
`transcriber_`, `GET /health` — проверка живости.
Загружает аудиофайл и создает задачу на расшифровку. Контракт приёма и опроса нормативен и живёт в
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
ответа, коды и условия. Менять его — необратимое действие
([CLAUDE.md](CLAUDE.md), «Работа»), и второго описания у него быть не должно.
**Параметры:** ## Состояния задач
- `audio` (form-data) - аудиофайл для расшифровки
**Пример запроса:** Перечень состояний, переходы между ними и число воркеров —
```bash [docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
curl -X POST \ данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
http://localhost:8080/api/transcribe \
-F "audio=@/path/to/your/audio.mp3"
```
**Ответ:**
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"file_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"status": "pending"
}
```
### GET /api/transcribe/:id
Получает статус задачи расшифровки по ID.
**Пример запроса:**
```bash
curl http://localhost:8080/api/transcribe/550e8400-e29b-41d4-a716-446655440000
```
**Ответ:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"file_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"created_at": "2024-01-01T12:00:00Z",
"updated_at": "2024-01-01T12:00:00Z"
}
```
### GET /health
Проверка работоспособности сервиса.
**Ответ:**
```json
{
"status": "ok",
"message": "Transcriber service is running"
}
```
## Статусы задач
- `pending` - задача создана, ожидает обработки
- `processing` - задача выполняется
- `completed` - задача завершена успешно
- `failed` - задача завершена с ошибкой
## Структура проекта ## Структура проекта
``` ```
transcriber/ transcriber/
├── main.go # Точка входа приложения ├── main.go # Точка входа: конфиг, миграции, сборка зависимостей, запуск
├── go.mod # Зависимости Go ├── internal/
├── models/ │ ├── entity/ # Модели: задача, файл, результат распознавания
── models.go # Модели данных ── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
├── database/ │ ├── config/ # Разбор config.toml
── database.go # Слой работы с БД ── metrics/ # Метрики Prometheus
├── handlers/ │ ├── service/ # Конвейер расшифровки
── transcribe.go # HTTP обработчики ── controller/
├── migrations/ │ │ ├── http/ # HTTP-обработчики
├── 001_create_files_table.sql │ ├── tg/ # Telegram-бот
└── 002_create_transcribe_jobs_table.sql │ └── worker/ # Фоновые воркеры
└── data/ └── adapter/
├── files/ # Директория для сохранения файлов ├── converter/ffmpeg/ # Конвертация аудио
── transcriber.db # SQLite база данных (создается автоматически) ── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
│ ├── telegram/ # Отправка сообщений
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
└── data/ # Каталог данных: база и файлы записей вместе
├── data.db # База хранилища (создаётся автоматически)
└── storage/ # Файлы записей в раскладке хранилища
``` ```
## База данных ## Хранилище
### Таблица `files` Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и
- `id` (TEXT) - UUID файла идентификаторов, а также механика захвата задачи воркером —
- `type` (TEXT) - MIME-тип файла [docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
- `size` (INTEGER) - размер файла в байтах порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
- `created_at` (DATETIME) - время создания в журнал при первом запуске.
### Таблица `transcribe_jobs`
- `id` (TEXT) - UUID задачи
- `status` (TEXT) - статус задачи
- `file_id` (TEXT) - ссылка на файл
- `created_at` (DATETIME) - время создания
- `updated_at` (DATETIME) - время последнего обновления
## Разработка ## Разработка
Для добавления новых миграций используйте goose: Схему двигают шаги миграций PocketBase на Go —
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой:
```bash ```bash
# Создание новой миграции task gate
goose -dir migrations create migration_name sql ```
# Применение миграций Что она гоняет, чем краснеет и какой отказ считается объявленным долгом —
goose -dir migrations sqlite3 data/transcriber.db up [CLAUDE.md](CLAUDE.md), раздел «Гейт».
# Откат миграций
goose -dir migrations sqlite3 data/transcriber.db down
+63
View File
@@ -4,9 +4,72 @@ version: '3'
vars: vars:
PROJECT: "transcriber" PROJECT: "transcriber"
# База диффа для шагов, которым нужна разница с основной веткой.
BASE: '{{.BASE | default "origin/master"}}'
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
# дрейф своего каталога, и выпадение одного не подменяется другим.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
tasks: tasks:
gate:
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
cmds:
- go build ./...
- go vet ./...
- |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "gofmt: файлы выше не отформатированы"
exit 1
fi
- go test ./...
- golangci-lint run
- task: docs
- task: tasks
- task: openspec
docs:
desc: 'Раскладка docs/ против канона'
cmds:
# Шаг обязан краснеть внятно, если скрипта нет, а не пропускаться молча.
- |
py=$(eval echo {{.DOCS_PY}})
if [ ! -f "$py" ]; then
echo "docs.py не найден: $py"
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
exit 1
fi
python3 "$py" check --base {{.BASE}}
tasks:
desc: 'Согласованность каталога задач'
cmds:
- |
py=$(eval echo {{.TASKS_PY}})
if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py"
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
exit 1
fi
python3 "$py" check --dir tasks
openspec:
desc: 'Форма openspec/config.yaml'
cmds:
- |
py=$(eval echo {{.OPENSPEC_PY}})
if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py"
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 1
fi
python3 "$py" check --dir .
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать # Контракт роли app_image (pet-project-server): собрать полный образ и затегать
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server. # его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
image: image:
+3 -6
View File
@@ -4,13 +4,10 @@ port = 8080
shutdown_timeout = 5 shutdown_timeout = 5
force_shutdown_timeout = 20 force_shutdown_timeout = 20
# Database configuration # Storage configuration
[database] # Единственный каталог данных: под ним лежат и база, и файлы записей.
path = "data/transcriber.db"
# File storage configuration
[storage] [storage]
path = "data/files" data_dir = "data"
# Yandex Cloud Configuration # Yandex Cloud Configuration
[yandex] [yandex]
+4
View File
@@ -0,0 +1,4 @@
{
"canon": 14,
"migrations": "migrations"
}
@@ -0,0 +1,49 @@
# ADR-2026-08-11. Границу распознавания доменного признака держит норма, а не код
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md](../../openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md), раздел `Decisions`, Решение 3
## Решение
Признак «работы нет» узнаётся через `errors.As`, то есть на любой глубине цепочки
ошибки. Встречный риск — отказ, к которому признак примешался по дороге, — закрыт
**требованием спеки**, а не проверкой в коде воркера.
Дословно из источника:
> Граница ставится **нормой, а не кодом**: спека требует, чтобы признак рождался
> только ответом хранилища на опрос этим же шагом, и запрещает слою сохранять
> чужой признак в цепочке своей ошибки.
## Почему
Приведение типа видело только вершину цепочки — потому и ломалось от первой же
обёртки. `errors.As` эту проблему устраняет, но устраняет симметрично: признак
теперь виден и там, где его никто не клал осознанно. Отказ, к которому признак
примешался обёрткой или `errors.Join`, воркер зачёл бы пустым прогоном — задача
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи.
Это тот же класс, от которого защищает инвариант «Принятая запись не теряется
молча», только с обратным знаком относительно чинимого дефекта.
Кодовый вариант рассмотрен и отвергнут по цене:
> **Рассмотрено и отвергнуто — научить воркер различать «признак на вершине» от
> «признака в глубине».** Отвергнуто по цене: `errors.As` такого различения не
> даёт вовсе, пришлось бы либо проверять вершину вручную (то есть вернуть
> приведение типа, которое чинится), либо заводить свой обход цепочки. Код
> усложняется ради случая, которого сегодня нет ни одного, а защита от него нужна
> на входе — при написании нового слоя, — где норма работает, а проверка в
> рантайме опоздала бы.
## Последствия
- `+` код остался коротким: одна проверка вместо разбора цепочки вручную.
- `+` защита стоит там, где ошибку совершают, — за письменным столом автора
нового слоя, а не в рантайме, где она уже случилась.
- `` норма не механизирована: её нарушение поймает только ревью или чтение.
Единственный `MUST` требования `pipeline` без машинного оракула — этот.
- `` правило живёт в двух документах: требованием в
[openspec/specs/pipeline/spec.md](../../openspec/specs/pipeline/spec.md) и
прозой в [conventions/errors.md](../conventions/errors.md), где оно нужно
автору в момент письма. Второй адрес ссылается на первый и нормой не является —
разойтись они могут только правкой, сделанной мимо спеки.
@@ -0,0 +1,54 @@
# ADR-2026-08-11. Отказ, который решено не проверять, объявляется поимённо
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md](../../openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md), раздел `Decisions`, Решение 2
## Решение
У `errcheck` включена настройка `check-blank`: присваивание отказа в `_` больше
не снимает замечание линтера. Место, где отказ решено не проверять, вносится в
`exclude-functions` поимённо.
Дословно из источника:
> Правило `errcheck` сегодня молчит на `_ = conn.Close()`: настройка
> `check-blank` не выставлена, а её умолчание — «пропускать». То есть
> реализация, выбрасывающая отказ в пустоту, удовлетворяет критерию приёмки
> «линтер не даёт замечаний `errcheck`», не удовлетворяя самому критерию —
> «отказ возвращается либо попадает в журнал».
## Почему
Решение принято не ради строгости, а потому что **оракул не мог упасть**. Задача
`errors-as-instead-of-typecast` закрывала два непроверенных `Close`, и её
критерий приёмки опирался на молчание линтера. Ревью дизайна показало, что этому
критерию удовлетворяет и негодная реализация: `_ = conn.Close()` теряет отказ
целиком, а линтер молчит. Критерий, который нельзя уронить, не проверяет ничего —
и вместе с ним в `CLAUDE.md` снималась запись о долге, то есть сигнал исчез бы
навсегда и без следа.
Очевидный путь был другим и отвергнут намеренно:
> **Рассмотрено и отвергнуто — дописать оба типа в `exclude-functions`
> `.golangci.yml`.** Соблазн сильный: список исключений там уже есть, и в нём
> записана ровно эта политика […] Отвергнуто: политика в конфиге относится к
> закрытию, у которого **отказ ничего не значит** […] Записав их в исключения,
> мы бы расширили политику молча, самим фактом добавления строки, и потеряли бы
> оба сигнала навсегда.
Включение проверено прогоном до правки кода: на тогдашнем коде правило не давало
ни одного нового замечания, то есть включалось чисто и отдельного коммита
приведения не требовало.
## Последствия
- `+` критерий «отказ не теряется молча» стал проверяемым машиной: мутация
(замена обоих мест на `_ = …Close()`) роняет линтер — проверено прогоном.
- `+` умолчание сместилось в сторону заметности: спрятать отказ по месту больше
нельзя, отказ от проверки виден в одном файле списком.
- `` осознанное игнорирование подорожало: вместо одного символа `_` нужна строка
в `exclude-functions` с полным именем метода. Для одноразового случая это
заметная церемония.
- `` список исключений будет расти, и каждая его строка — это политика на весь
проект, а не на одно место. Разрастание списка — сигнал, что правило выбрано
неверно, и повод пересмотреть эту запись.
@@ -0,0 +1,45 @@
# ADR-2026-08-11. Наружу расширение выходит только приведённым к перечню
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md](../../openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md), раздел `Decisions`
## Решение
Расширение принятой записи приводится к закрытому перечню известных форматов
прежде, чем уйти меткой метрики; всё, чего в перечне нет, заменяется одним общим
значением. Имя файла на диске при этом не трогается — там расширение остаётся
тем, каким пришло.
Дословно из источника:
> Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
> задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как
> «починено»; приводить расширение везде, включая имя файла на диске, —
> раскладка каталога записей объявлена необратимой и меняется решением человека,
> а не по ходу починки журнала.
## Почему
Расширение берётся из имени, которое дал отправитель, дословно: имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. Оно уходило меткой метрики, а страница метрик отдаётся без проверки
отправителя. Канал оказался шире того, ради которого задача заводилась: журнал
читает владелец сервиса, метки — кто угодно, и то же значение оседает в
хранилище метрик. Тем же каналом множество значений метки становится
неограниченным: их задаёт анонимный отправитель.
Отказ от нормализации на диске — не экономия, а граница обратимости: формат
имени файла и раскладка `data/files` объявлены необратимыми, и меняются они
решением человека под свою задачу, а не попутно с починкой журнала.
## Цена
- Перечень форматов стал нормой и требует ведения: формат, который сервис
начнёт принимать, до внесения в перечень будет виден в метрике как общее
значение, неотличимо от чужого хвоста.
- Форма метки размера принятой записи изменилась — ведущая точка пропала
(`.mp3` стало `mp3`). Ряды, собранные до выкладки, перестают пополняться.
- Настоящий формат записи, попавшей в общее значение, остаётся видимым только в
журнале — по полю пути строки приёма и полю формата строки конвертации.
- Хвост расширения по-прежнему уходит в журнал внутри пути файла. Это остаток,
он записан в [../security.md](../security.md), и своей задачи у него пока нет.
@@ -0,0 +1,80 @@
# Хранилище, файлы и вход переезжают в PocketBase
- **Дата:** 2026-08-11
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
разведки `pocketbase-admin-fit`
## Решение
PocketBase заменяет SQLite с goqu и goose и берёт на себя три вещи разом:
состояние задач и метаданные, файлы записей своим полем и своей раскладкой на
диске, вход пользователей через Authelia своим провайдером OIDC. Панель
администратора работает по всем трём частям только в таком составе.
## Почему
Разведка мерила панель по трём частям, и порознь ни одна перевода не оправдывала.
Правку записей панель даёт целиком, а две остальные упираются в то, где лежат
данные. Цитата из источника, раздел про пользователей:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
И раздел про файлы:
> Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
> покажет строкой — прослушать и скачать запись по ней нельзя. Способа
> сослаться на файл, уже лежащий на диске мимо её каталога, нет.
Там же то, что связывает файлы с сохранностью архива:
> **Резервные копии накрывают ровно её каталог.** Файлы, оставленные снаружи, в
> них не попадут — то есть панель и встроенное резервное копирование покупаются
> одной и той же ценой.
Отвергнуты два половинчатых пути, и оба по одной причине — они покупают перевод,
не покупая того, ради чего он затевался:
> **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
> панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают.
> Довод, ради которого перевод затевался, пропадает целиком.
>
> **Оставить вход у приложения, а PocketBase взять только хранилищем.**
> Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из
> трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
Запись попадает в журнал по двум основаниям сразу. **Откат дорогой:** меняется
раскладка файлов на диске, а она в `../../CLAUDE.md` названа необратимой.
**Пересматривается прежнее решение:** вход через OIDC собирались делать в самом
приложении — так это записано в `../architecture.md`, «Открытые вопросы», и так
поставлена задача `oidc-login`. Парного статуса «заменено на» прежняя запись не
получает: своего ADR у неё нет, решение жило открытым вопросом архитектуры.
## Последствия
- `+` панель даёт владельцу править записи, видеть пользователей и слушать сами
файлы. Проверено на версии 0.39.10; в библиотечной сборке панель отдаётся по
адресу `/_/` того же порта.
- `+` база и записи съезжаются под один каталог, и копия сервера накрывает их
разом. Своё копирование по расписанию с выгрузкой в S3-совместимое хранилище у
PocketBase тоже есть, но копии сервер уже делает своими средствами — берём мы
встроенное или нет, здесь не решено.
- `+` требование CGO уходит: PocketBase ходит в SQLite через
`modernc.org/sqlite`, и пробник собрался при `CGO_ENABLED=0`. Свойство стека в
`../../CLAUDE.md` перестаёт быть верным.
- `` раскладка `data/files` меняется необратимо: файл ложится в
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
переносятся по прежнему решению задачи `pocketbase-storage`.
- `` вход перестаёт быть нашим: задача `oidc-login` переписывается с
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
Что делать с сессией и где она живёт, решает уже не наш код.
- `` появляется секрет, которого не было: пароль суперпользователя панели. Сама
PocketBase Authelia к панели не подпускает — ни OIDC, ни второй фактор у
коллекции суперпользователей включить не удалось. Своё ограничение по списку
адресов у неё есть, но им же можно запереть себя: сброса в наборе команд нет.
- `` панель висит на том же порту, что и приложение, а порт опубликован в
интернет через обратный прокси. **Закрывает её контур:** тем же решением адрес
`/_/` закрывает Authelia на прокси, пропуская группу администраторов. Приложение
тут ни при чём, и задачи в беклоге у этого нет.
@@ -0,0 +1,66 @@
# Очередь остаётся своей таблицей, но коллекцией PocketBase
- **Дата:** 2026-08-11
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
разведки `job-queue-choice`
## Решение
Очередь задач остаётся своей таблицей и становится коллекцией PocketBase: захват
идёт одним запросом с `RETURNING`, число попыток лежит колонкой, нарастающая
пауза выражается существующим `delay_time`, а исчерпавшая попытки задача
переходит в состояние «мертва» вместо сегодняшнего `is_error = 1`. Готовую
библиотеку очереди не берём.
## Почему
Разведка искала готовую очередь и нашла, что для PocketBase её нет:
> Единственная очередь в списке — `pocketbase-queue`, написана на TypeScript и
> работает из JS-хуков; из Go её не подключить.
Отсюда разрез, который и решил дело:
> выбор идёт не между готовым и своим, а между **своим в коллекции PocketBase** и
> **чужой очередью, живущей рядом с PocketBase и мимо её панели**. River и goqite
> про PocketBase не знают.
Главный довод в пользу чужой библиотеки — транзакционный захват — снялся
замером:
> движок за `modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём
> есть, и на трёх горутинах разом запись получила **ровно одна**. Это снимает
> главный довод в пользу чужой библиотеки: транзакционность захвата покупается
> одной строкой запроса, а не новой зависимостью.
Отвергнуты два кандидата, и оба с названной ценой:
> **River с драйвером SQLite** — покупает повторы, счётчик и мёртвых готовыми, но
> выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и
> переписывает конвейер в цепочку задач.
>
> **goqite** — не отвечает ни на один из трёх вопросов задачи целиком, а его
> предел выдач молча теряет запись.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: взять
готовую библиотеку вместо своего кода — первое, что предлагают на такой вопрос, и
без записанной причины его предложат снова. Принцип «очередь таблицей» из
[../architecture.md](../architecture.md) этим решением подтверждён, а не
пересмотрен, поэтому парного статуса «заменено на» никакая запись не получает.
## Последствия
- `+` очередь видна и правится в панели администратора: мёртвая задача повторяется
снятием состояния, а не запросом в консоли сервера. Ровно за это и куплен
перевод хранилища на PocketBase
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)).
- `+` ни одной новой зависимости: River добавил бы 46 пакетов в сборку, goqite — 3.
- `+` захват перестаёт быть двумя запросами без транзакции, и это перестаёт быть
местом, которое держится на том, что три воркера читают три разных состояния.
- `` повторы, счётчик попыток и очередь мёртвых пишем сами, и корректность
захвата наша. Проверяется это тестами задачи `pocketbase-storage`, а не
чужим набором проверок.
- `` захват идёт сырым запросом мимо записей PocketBase: хуки коллекции на нём
не срабатывают, и поле времени изменения проставляет наш код.
- `` приборной панели очереди — числа ждущих, упавших, среднего времени — не
появляется. Смотрим таблицу коллекции в панели PocketBase, отбирая фильтром.
+76
View File
@@ -0,0 +1,76 @@
# Приложение пишем на Vue, а Node входит в гейт и в образ
- **Дата:** 2026-08-11
- **Источник:** [../research/spa-framework.md](../research/spa-framework.md) —
записка разведки `spa-framework-choice`
## Решение
Приложение пишем на **Vue 3** с роутером пятой версии и собираем **Vite** в
статику, которую бинарник вшивает через `go:embed` и раздаёт сам. Маршруты
задаём своей таблицей через `createRouter`; сборочную надстройку роутера под
маршруты по файлам не включаем.
Вместе с этим в проект входит **шаг сборки статики**: Node и `npm` становятся
нужны на машине разработчика, отдельным шагом в `task gate` и слоем сборки в
`Dockerfile`.
Это два решения, а не одно, но принимаются они вместе: шаг сборки появляется при
любом из трёх кандидатов, и отдельно от выбора фреймворка его обсуждать не о чем.
## Почему Vue
Разведка мерила три кандидата на одном и том же экране и нашла единственное
различие, которое расходится в разы:
> Различает единственное — **размер того, что скачивает телефон**, и он
> расходится вчетверо.
Вшивание в бинарник, цена шага сборки в гейте и установка на телефон у всех трёх
оказались одинаковыми и потому ничего не решают.
По размеру Vue стоит посередине — 33 326 Б на четыре экрана против 17 314 у
Svelte и 72 402 у React. Выбран он не по этому числу, а по устойчивости
экосистемы, и оба отвергнутых кандидата отвергнуты с названной ценой:
> **Svelte** — легче Vue вдвое, но своего роутера не имеет, а тот, что есть,
> держит один человек. Владелец выбрал экосистему, которая переживёт проект, а не
> минимальный размер: 33 КБ на телефоне не отличаются от 17 КБ на глаз, а
> брошенная зависимость отличается.
>
> **React** — вчетверо тяжелее Svelte и вдвое тяжелее Vue, а взамен даёт
> экосистему, которой приложению на четыре экрана не на что потратиться.
Роутер берём пятой версии, а не четвёртой, по тому же доводу: она стабильна с
29 января 2026 и несёт метку `latest`, то есть чинить будут её, а не
предшественницу. Её сборочная надстройка добавляет 34 пакета в установку, и это
принятая цена; на собранный файл она не влияет и необязательна.
## Почему это ADR
Запись проходит триггер **дорогим откатом**: переход на другой фреймворк
переписывает все экраны разом, а не один файл. Шаг сборки сюда же — он меняет
требования к машине разработчика и к образу, и снять его потом можно только
вместе с приложением.
Прежнего решения запись не пересматривает: htmx был снят решением о SPA от
2026-08-10, до заведения этого журнала, и парного статуса «заменено на» ставить
нечему.
## Последствия
- `+` разметка отделена от кода однофайловым компонентом, а роутер и хранилище
состояния идут из тех же рук, что и сам фреймворк: третьей библиотеки под них
заводить не нужно.
- `+` собранная статика — три файла и значок, поэтому `go:embed` берёт каталог
обычной строкой, а бинарник остаётся самодостаточным.
- `` **гейт перестаёт зависеть только от Go.** Красный шаг сборки статики
становится таким же поводом остановиться, как красный `go build`, а машина
разработчика получает второе требуемое окружение сверх `ffmpeg`.
- `` **в образ добавляется слой Node** ради шага, результат которого — три
файла; насколько дольше собирается образ и насколько тяжелеет, не замерялось.
- `` в проект приходит `node_modules` на 92 МБ и 84 пакета, за которыми надо
следить отдельно от зависимостей Go: `gitleaks` и `golangci-lint` про них
ничего не знают.
- `` приложение весит 33 КБ там, где на Svelte весило бы 17. Разница куплена
сознательно и обратно не отыгрывается.
@@ -0,0 +1,51 @@
# Проверки не зовут внешних программ
- **Дата:** 2026-08-11
- **Источник:** openspec/changes/archive/2026-08-11-fix-http-handler-tests/design.md
## Решение
Тесты приёма получают длительность записи от подставного источника метаданных, а
не от `ffprobe`. Годность содержимого судит адаптер, тест судит наш код.
## Почему
Цитата из источника, раздел `Decisions`:
> **проверять приём сквозь настоящий `ffprobe`.** Отвергнуто: это проверка
> внешней программы, а не нашего кода. Она найдёт отказ `ffprobe` и не найдёт
> ошибку в приёме — ровно наоборот тому, зачем эти тесты писались.
Отвергнуты там же два очевидных пути, и оба по записанным правилам проекта, а не
по вкусу:
> **положить настоящую запись в `testdata`.** Отвергнуто дважды: `.gitignore`
> строкой `*.m4a` её не пустит, а `CLAUDE.md` прямо говорит, что `testdata` в
> проекте нет и тесты создают нужное во временном каталоге. Снимать запрет ради
> теста — менять правило проекта под удобство одного файла;
>
> **порождать запись `ffmpeg` прямо в тесте.** Отвергнуто: проверка приёма
> начинает требовать установленных `ffmpeg` и `ffprobe`, а критерий приёмки
> требует обратного — прогона с `ffprobe`, убранным из `PATH`.
Решение попадает в журнал как **намеренный отказ от очевидного подхода**: файл с
настоящей записью в `testdata` — первое, что сделал бы человек, и отказ от него
из кода не виден.
## Последствия
- `+` прогон проверок на чистом клоне зелёный без подготовки файлов руками и без
установленных внешних программ. Проверено сборкой тестового бинарника и
прогоном под `env -i PATH=<пустой каталог>`.
- `+` ветка отказа чтения метаданных впервые проверяема: подставной источник
умеет вернуть ошибку, настоящий `ffprobe` по заказу не отказывает.
- `+` проверки не держат состояния процесса: каталог хранения задаётся снаружи,
`os.Chdir` ушёл, и параллельный прогон перестал быть запрещённым.
- `` разбор вывода настоящего `ffprobe` не проверяется ничем: своего теста у
`internal/adapter/metaviewer/ffmpeg` нет. Формально покрытие не потеряно —
прежние проверки звали его так, что он всегда отказывал, — но дыра теперь
наша и записана в [../review.md](../review.md), «Перестали проверять
сознательно».
- `` правило распространяется на будущие проверки: узел, чья работа и есть
обращение к внешней программе, придётся проверять иначе, и чем — здесь не
решено.
@@ -0,0 +1,63 @@
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
## Решение
Поле файла в хранилище **не помечается защищённым**: ссылка
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
## Почему
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
> `GET /api/status/:id`, а не ниже.
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
условие сломал, и вот чем:
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
> бы бессрочным ключом к чужому аудио.
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
Storage — тот несёт полный URL объекта.
Отсюда вторая половина решения, без которой первая недопустима: **отказы
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
прослеживаемость от этого не страдает.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
файлы токеном предложат снова, и без записанной причины предложение выглядит
бесплатным.
## Последствия
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
приложение получает файл без отдельного механизма выдачи токенов.
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
только расширение, а не имя.
- `` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
показывающий ссылку, наследует это свойство.
- `` появилась норма, которую держит не построение, а внимание: всякий новый
отказ хранилища надо обрывать руками. Норму сторожат требование capability
`storage` и проверка журнала, но компилятор — нет.
- `` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
- Решение действует до разграничения доступа: задачи `oidc-login` и
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
записью.
@@ -0,0 +1,50 @@
# Каталог данных задаётся одним ключом `[storage] data_dir`
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Ключи конфигурации: два пути заменяются одним каталогом»
## Решение
Ключи `[database] path` и `[storage] path` уходят. Вместо них — один
`[storage] data_dir` со значением `data`: база и файлы записей лежат под одним
каталогом, и по-другому хранилище не умеет.
Выбор сделан человеком 2026-08-12 из трёх названных вариантов.
## Почему
Имя ключа конфигурации проект объявил необратимым
([../../CLAUDE.md](../../CLAUDE.md), «Работа»): переименование правится не в
одном файле, а в конфигурации на сервере и в выкладке, и молча ломает запуск.
Поэтому выбор ушёл человеку, а не был принят по ходу.
Цитата источника о цене каждого варианта:
> - `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
> сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
> этом уже занято capability, но в конфигурации оно значит ровно то же — где
> лежат данные;
> - `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
> вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует
> второго необратимого переименования;
> - `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
> каталог на диске, и секция с таким именем читается как «настройки каталога»,
> а не «настройки хранилища».
Запись попадает в журнал по **дорогому откату**: переименование ключа стоит
правки конфигурации на сервере и в выкладке, а ошибка проявляется отказом старта.
## Последствия
- `+` имя ключа не называет поставщика, и смена библиотеки хранилища второго
необратимого переименования не потребует.
- `+` каталог данных один, и запрет «боевой каталог не трогать» покрывает и базу,
и записи одной строкой.
- `` слово `storage` в проекте теперь значит три вещи: capability, само
хранилище и секцию конфигурации. Поле записи о файле от этого переименовано в
`location` — чтобы смыслов было три, а не четыре.
- `` прежние конфигурации несовместимы: сервис на старом `config.toml`
поднимется на умолчании `data`, а не на прежних путях. Данные при этом не
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
нулевой при переносе.
+46
View File
@@ -0,0 +1,46 @@
# Журнал решений
Одна запись — одно решение. **ADR продвигает уже написанное решение, а не
сочиняет его заново**: запись цитирует решение и ссылается на источник —
`openspec/changes/archive/<id>/design.md`, а у решения, принятого разведкой без
изменения, на её записку.
## Когда заводить
Верно одно из трёх:
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
Не заводить для рутины и для того, что видно из кода и `git log`.
## Соглашения
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле.
## Записи
Новые сверху.
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
не имеют — сочинять их задним числом правило запрещает.
+23
View File
@@ -0,0 +1,23 @@
# Краткий заголовок решения
- **Дата:** ГГГГ-ММ-ДД
- **Источник:** openspec/changes/archive/<id>/design.md — либо записка разведки,
если решение принято без изменения
Статус ставится тем же полем и только при пересмотре:
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`.
У активной записи поля нет.
## Решение
Что именно решено — одной фразой.
## Почему
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
год было понятно без чтения переписки.
## Последствия
- `+` что стало лучше.
- `` чем платим: ограничения, риски, нагрузка на сопровождение.
+197
View File
@@ -0,0 +1,197 @@
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы».
Заведены три capability:
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
нормируют проверки, написанные задачей `http-handler-tests-never-green`
2026-08-11;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
коде. Задача, которая его трогает, дописывает спеку своей capability.
## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md).
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
достаётся снова по истечении срока захвата и проходит шаг заново.
- **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
`main.go`.
## Компоненты
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, delivery -->
| Компонент | Где | Что делает |
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Три
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
а тот, кто её захватил.
## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
## Эксплуатация
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/conversion, recognition -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
три воркера опрашивают базу вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
сам.
## Деплой
Образ собирается по контракту роли `app_image`: `task image` даёт
`transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует —
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
`inv pl -- transcriber` из `pet-project-server`.
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
процесс работает под непривилегированным пользователем `transcriber`.
## Открытые вопросы
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
и как связываются пользователь Telegram и пользователь веба. Панель
администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
сборкой Vite — 2026-08-11,
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор
компонентов.
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом.
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
потолок проекта — шесть часов, и он взят с запасом, а не замером.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново.
Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет
публичный контракт приёма и потому идёт через решение в `adr/`.
- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
или нет, тоже не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
это обходится, никто не мерил.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет.
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания.
+72
View File
@@ -0,0 +1,72 @@
# Конвенции кода
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
система делает, и от [../architecture.md](../architecture.md), который описывает,
как она сложена.
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения.
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты с
severity — в [CLAUDE.md](../../CLAUDE.md).
## Откуда взяты и что с расхождениями
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
шаге и дублируется воркером.
Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа
до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом
месте больше не долг, а регрессия.
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно
как **долг, а не как нарушение**: правила действуют на новый код, переписывание
существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что
находка на этом месте уже известна и новой не считается.
## Записи
- [logging.md](logging.md) — логирование: уровень по адресату, единая логирующая
точка на доменной границе, словарь полей, `ext.*`, что не логируем.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is` и
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
типизированной.
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID, разбор на входной границе, естественные ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
над `fetch`, показ ошибок и состояний списка.
## Механизировано
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` |
| Форматирование исходников | `.golangci.yml``gofmt` |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
оставшееся прозой, проверяет человек на каждом ревью заново.
+124
View File
@@ -0,0 +1,124 @@
# Конфигурация
Конвенция: *как* устроена и грузится конфигурация transcriber (TOML).
Правила оформления кода (How), не спецификация поведения.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
проверки пустых ключей внутри адаптеров.
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
участвует.
## Принципы
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
- Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
прикладном коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — перезапуск процесса.
## Файл и поиск
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
`config.toml` не коммитится.
## config.dist.toml — самодокументируемый образец
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы;
- **единицы измерения**, если применимы — секунды, байты, доля `01`.
```toml
[server]
port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.dist.toml` — источник истины по составу
полей.
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
бекендов или внешних сервисов), обязательность и опциональность полей определяет
значение `type`, а не фиксированный список секции.
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
обязательных полей; поля других значений не требуются. Неизвестное значение —
ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.dist.toml`:
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
полей (зачем, границы, единицы — как у обычных полей);
- так из примера видны все варианты и поля каждого, не открывая код.
Дискриминатора в transcriber пока нет; правило записано на случай второго
распознавателя.
## Секреты
Секреты доставляет **выкладка**, рендеря их прямо в `config.toml` (transcriber:
Ansible из `pet-project-server`). Приложение просто читает TOML — отдельного слоя
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки.
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
## Проверка и остановка на старте
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
и выход с ненулевым кодом, не стартуем наполовину.
Что проверяем:
- обязательные поля заданы;
- каталоги хранилища существуют и доступны на запись;
- границы числовых полей соблюдены;
- ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет.
## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
`Database`, `Storage`, `Yandex`, `Telegram`).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест.
+66
View File
@@ -0,0 +1,66 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
и разбора нет. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
в transcriber нет.
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
хронологией не является: порядок берут по колонке времени с ключом.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в
репозитории, разбор — на входных границах. Самодельных генераторов по месту
вызова не заводим.
## Канонический вид — lowercase
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
побайтовое, поэтому любой внешний id (URL, форма, поле запроса) обязательно
проходит разбор до запроса к БД — разбор проверяет формат и нормализует
регистр (base32 ULID нечувствителен к регистру при декодировании).
- Синтаксически неверный id считаем несуществующей сущностью (404), без похода
в БД.
## Естественные и составные ключи — для деталей
- У таблиц-деталей и связей допустим естественный или составной ключ вместо
ULID, когда он есть по природе данных. Отдельный ULID там — мёртвый вес.
- Прочие генерируемые идентификаторы — тем же способом, что и ключи сущностей:
единый формат, сортируемость, корреляция в логах.
## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
кодом — ради панели владельца: правка руками не должна заводить состояние,
которого конвейер не знает. Цена названа: шестое состояние потребует нового
шага схемы.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+144
View File
@@ -0,0 +1,144 @@
# Ошибки
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
строятся, оборачиваются и проверяются.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
пакетов ошибок в проекте и так нет.
## Базовая идиома: stdlib
- Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а
не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не умолчание.
## Обёртка и контекст
transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш.
Поэтому внутри приложения обёртка `%w`**умолчание**, чтобы `errors.Is` и
`errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`.
- `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v`
когда причину сознательно **не** раскрываем.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а
трансляцией на внешней границе (см. ниже).
Стиль сообщения:
- со строчной, без точки в конце, без «failed to» и «error» — обёртка и так
читается как «контекст: причина»;
- контекст — операция или субъект: `"acquire job: %w"`, а не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний.
*Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`.
## Проверка ошибок
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше
по коду не торчал `database/sql`.
- Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа.
- **Признак домена читается только из ответа того шага, который его породил.**
`errors.As` распознаёт признак на любой глубине цепочки, а не только сверху,
— поэтому слой, придающий отказу собственный смысл, чужой признак в свою
цепочку не сохраняет. Иначе воркер примет отказ, к которому признак
примешался, за этот признак: зачтёт настоящий сбой пустым прогоном, и задача
продолжит переопрашиваться без единой записи в журнале. Норма записана требованием
[pipeline](../../openspec/specs/pipeline/spec.md).
## Sentinel и типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
которые ветвится код. Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel.
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
## Граница и трансляция: приватный и публичный канал
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
форма зависит от канала и от того, кто его видит:
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
API). Сюда отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id
= …», а не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма
случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение
остаётся без якоря, а диагностика ищется по записи доменной границы.
Заводить транспорту собственный идентификатор запроса ради ключа — решение
уровня спеки, а не умолчание;
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба:
| Доменная ошибка | Статус | Сообщение |
| --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
любую ошибку заведения задачи.
### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
вычищают на границе клиента;
- это **не** канал для разовых отказов — те остаются нейтральными;
- **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
пользователь Telegram видит отдельный человекочитаемый текст — это часть
правила соблюдена.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
границы **нет**: паника в шаге конвейера роняет процесс целиком.
## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
+262
View File
@@ -0,0 +1,262 @@
# Логирование
Конвенция: *как* и *когда* писать логи в transcriber. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам (что
система обязана залогировать как часть контракта capability) живут в спеках
OpenSpec.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит `INFO` и не
настраивается; `msg` — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
## Принципы
- Структурированный JSON (`slog.JSONHandler`), один формат для разработки и для
продакшена.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт отбор и сведение через
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
*Расхождение:* сегодня `msg` — предложение вида `Starting conversion job`,
поля `capability` нет, отдельной категории перехода нет.
## Уровни
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько громко
сломалось». `slog` даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в transcriber |
| --- | --- | --- |
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
Правила:
- Уровень **не зависит от capability**: `ERROR` в приёме и в распознавании
одинаково серьёзны.
- `WARN` не значит «ничего страшного». `WARN` значит «может стать проблемой».
Если это не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
`DEBUG` (норма, владельцу разбирать нечего), а не `ERROR`.
- **Событийное — `INFO`, рутинно-частое — `DEBUG`.** Операция по реальному
действию (приём записи, запуск распознавания, отправка текста) идёт на `INFO`.
Повторяющаяся служебная операция, которую запускает таймер или опрос и которая
сама по себе события не несёт (проверка здоровья, пустой прогон воркера,
опрос готовности операции), — на `DEBUG`: на `INFO` она зашумляет разбор.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
завершаем процесс с ненулевым кодом.
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
## Время
- Поле — `time` (ключ `slog` по умолчанию).
- UTC, RFC 3339 с долями секунды, суффикс `Z`.
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
лексикографическую сортировку. Часовой пояс есть только у **отображения**.
## Поля: словарь имён
Главное условие — **единый словарь**: одно поле, одно имя по всему коду.
- Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
`ext.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии,
чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("job_id", job.Id, "capability", "conversion")
```
- Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`.
## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
накапливается в цепочке `%w`.
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`).
- Транспорты переводят возвращённую ошибку в свой ответ и **не логируют** её
повторно — иначе один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой доменной
ошибки ровно один логирующий; уровень выбирает он.
| Класс отказа | Кому | Уровень |
| --- | --- | --- |
| негодный ввод, задача не найдена, действие сейчас недопустимо | пользователю (он уже получил ответ на поверхности) | `DEBUG` |
| запись распознана пустой, задача досталась повторно | владельцу, «может стать проблемой» | `WARN` |
| сбой БД, диска, недоступность внешнего сервиса | владельцу, в разбор | `ERROR` |
- **Повторяющийся сбой фонового цикла — `WARN`, а не `ERROR`.** Одиночный
промах шага временный: задача останется в своём состоянии, и следующий тик
повторит. Тот же класс сбоя в синхронной операции приёма — `ERROR`, потому что
операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а
наличие штатного повтора.
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о поведении
зависимости, а не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
мимо `slog` целиком.
## Внешние сервисы: логируем все вызовы
**Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.
Уровни вызова:
- `INFO` — успешный **событийный** вызов (заливка объекта, запуск распознавания,
отправка сообщения, конвертация);
- `DEBUG` — успешный **рутинно-частый** вызов (опрос готовности операции,
длинный опрос обновлений);
- `WARN` — попытка не удалась, делаем повтор;
- `ERROR` — повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx —
это успех на транспортном уровне; решение «это ошибка» принимает доменный
вызывающий.
Тело запроса и ответа — только на `DEBUG` и **после** вычистки секретов.
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются
никак.
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
линтером не выражается.
- **`GET /health` и `GET /metrics` логируем на `DEBUG`** — их дёргают
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой,
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
по которому разбирают отказы, эта таблица не попадает.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
величина, а корреляцию держат идентификаторы сущностей. Чем при этом
прослеживается приём, нормирует спека `intake`, а не эта запись.
Дополнительно:
- Тела запросов и ответов внешних сервисов — только на `DEBUG`, с вычисткой
секретов и обрезкой по длине.
- При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога.
*Расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
описан в [../security.md](../security.md).
## Куда пишем и уровень
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
раскладываем по файлам.
- Базовый уровень в продакшене — `INFO`; `DEBUG` включается конфигом при
необходимости. При разработке — `DEBUG`.
*Расхождение:* поля конфигурации под уровень лога нет.
## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+104
View File
@@ -0,0 +1,104 @@
# Веб-UI
Конвенция: *как* мы пишем код приложения. Это правила оформления кода (How), а не
спецификация поведения — что именно приложение показывает и какие действия
обязано поддерживать, живёт в спеке OpenSpec.
Фреймворк выбран 2026-08-11 разведкой `spa-framework-choice`:
[ADR](../adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
[research/spa-framework.md](../research/spa-framework.md). Прежняя редакция
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
целиком вместе со сменой решения на SPA 2026-08-10.
**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на
пробном экране, а не из написанного кода: первым их применяет и проверяет
`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, —
повод править эту запись, а не обходить её молча.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md).
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения
пока нет.
## Что решено про само приложение
- **Приложение — SPA**, а не страницы, отрисованные сервером. Сервер отдаёт
контракт данных, разметку собирает клиент.
- **Приложение ставится на телефон** и запускается с ярлыка: манифест, иконки,
service worker.
- **Статика вшивается в бинарник** через `go:embed` и раздаётся им же. Внешнего
веб-сервера под статику не заводим, бинарник остаётся самодостаточным.
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
выполнения нет.
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
цели [web-access](../../tasks/items/web-access.md). Без сети приложение
показывает состояние, а не пустой экран.
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель
[ready-notification](../../tasks/items/ready-notification.md).
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом.
## Фреймворк и сборка
- **Vue 3, TypeScript, сборка Vite.** Серверной отрисовки нет, надстройки над
фреймворком (Nuxt) нет: она ждёт рядом процесс Node, а у нас статика в
бинарнике.
- **Компонент — однофайловый, `<script setup lang="ts">`.** Options API не
пишем: два способа объявить компонент в одном приложении — второй способ
делать то же самое.
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
Vite, руками их не задаём: от этого зависит обновление установленного
приложения.
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
роняет гейт наравне с `go build`.
## Маршруты
- **Четыре экрана, одна таблица маршрутов** через `createRouter`. Маршруты по
файлам не включаем: сборочная надстройка роутера пятой версии стоит 34 пакета
в установке и на четырёх маршрутах не окупается.
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
пути внутри `/api/` в приложение не проваливаются никогда.
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
посередине.
## Состояние и обращение к API
- **Состояние экрана живёт в экране** — `ref` и `computed` по месту. Общее между
экранами выносим в composable-функцию `use…`.
- **Хранилища состояния (Pinia) не заводим**, пока два экрана не потребуют одних
и тех же данных одновременно. Заведём — это правка этой записи с названной
причиной.
- **Обращение к API — через `fetch` и через одну свою обёртку.** Сторонних
клиентов (axios и подобных) не берём: внешних ресурсов у нас нет, а разбор
ответа и отображение ошибки всё равно свои.
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
не `Response`.
## Показ ошибок и состояний
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
([json-api-for-spa](../../tasks/items/json-api-for-spa.md)), и второй словарь
на клиенте разошёлся бы с первым.
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
строкой «связи нет», а не пустым экраном и не сообщением браузера.
- **У каждого списка три состояния и все три нарисованы:** загружается, пусто,
есть данные. Пустой список без надписи неотличим от незагруженного.
- **Текст, который видит пользователь, — русский** ([CLAUDE.md](../../CLAUDE.md),
«Язык»). Код и идентификаторы английские, включая имена компонентов и файлов.
## Что не решено
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
готовый способен удвоить собранный файл
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
узнала»).
- **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md).
- **Где живёт сессия и как приложение узнаёт вошедшего** — открытый вопрос
«Учётные записи» в [../architecture.md](../architecture.md).
+149
View File
@@ -0,0 +1,149 @@
# Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
CGO сборке не нужен.
Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается — изменение только новым шагом: применённое хранилище считает по
имени файла.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
хранилище, задаёт сервис, и это `<uuid><расширение>`.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта».
## Коллекции
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — три разные записи.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл; пусто у копии в Object Storage |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
| `size` | INTEGER | Размер в байтах |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
### `transcribe_jobs`
Задача расшифровки и она же очередь.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
| `source` | select | `api`, `telegram`, `unknown` |
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | editor | Результат распознавания |
| `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один.
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
может только владелец панели. Проверено прогоном: анонимный запрос к
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
`/api/settings` и `/api/crons``401`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом чтении задачи и при каждом захвате.
- **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
хранилище **в журнал не пишется** — оно последняя часть ссылки.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим.
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
работы достался другому, завершается без записи и без ответа отправителю.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
в Object Storage: отказ SDK несёт полный URL объекта.
## Настройки с числовым значением
| Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- |
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
+115
View File
@@ -0,0 +1,115 @@
# Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт —
«зачем и для кого».
## Цель
Превращать записанную речь в текст, который можно читать и искать, и хранить
этот текст вместе с записью.
**Сервис — архив, и это решено 2026-08-11.** Прежде граница читалась «отдаём
текст и на этом заканчиваем»; теперь расшифровки и исходные записи лежат
бессрочно, а список отбирается по темам. Причина в основном сценарии: семейный
архив загружают один раз, а возвращаются к нему годами.
**Потребители** — список закрытый: он определяет, что считать нужным, а что
интересным.
| Кто | Что ему нужно от нас |
| --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
несколько часов через Telegram не проходит вовсе.
Цель достигнута, когда:
- запись любого распространённого формата принимается без предварительной
подготовки, включая дорожку из видео;
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при
достижении предела;
- сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
узнаёт о его готовности, не держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится
приглашение ещё одного человека.
## Что целью не является
Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
через границу.
- **Правка текста руками.** Машинную вычитку расшифровки отдаём — литературный
текст стоит рядом с сырым, — а редактором не становимся: текст руками не
правим, не размечаем и не экспортируем в форматы документов. Граница сдвинута
2026-08-11: до того запрет читался «расшифровку отдаём как есть».
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
Направление — цель [text-insights](../tasks/items/text-insights.md).
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
входит своим паролем, потому что подпустить к ней внешнего провайдера
PocketBase не даёт.
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем — граница цели
[web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов, папками и общим доступом к чужим
записям сервис не становится.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв
доступа в Authelia.
## Типовые сценарии
Первые два — основные, и сегодня не работает ни один: приложения нет.
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
Загрузка показывает ход. Файл, который уже загружали, не грузится второй
раз. Когда текст готов, приходит уведомление; в списке запись видна
заголовком и темами.
2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями. Работает сегодня.
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Работает сегодня, но без токена и без разграничения
доступа.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить.
## Референсы
Где смотреть prior art, когда упёрлись.
- **Yandex SpeechKit, отложенное распознавание** — модель `deferred-general`,
которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи.
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником
их не становится: заводит и проверяет людей по-прежнему Authelia.
+29
View File
@@ -0,0 +1,29 @@
# Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
**Каждый вывод — с числами и командой, которой получен**, чтобы его можно было
перепроверить.
## Как снималось
На живом потоке не снималось ничего: поведение внешних сервисов на границах не
проверяли. Все записи сделаны в песочнице — на пустой базе либо в каталоге вне
репозитория.
Внешних источников, о которых разведка нужна, четыре — Telegram Bot API, Yandex
SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, что стоит
открытыми вопросами в [../architecture.md](../architecture.md) — «Долгие
записи», «Формат для распознавания», «Видео». Первый же ответ на любой из них
заводит здесь запись с командой и условиями замера.
## Записи
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase |
| 2026-08-11 | [PocketBase: что даёт панель администратора](pocketbase.md) | Записи, пользователи и файлы в панели версии 0.39.10 |
+41
View File
@@ -0,0 +1,41 @@
# gRPC-клиент SpeechKit: когда закрытие вообще может отказать
Отвечает на вопрос, возникший по ходу задачи `errors-as-instead-of-typecast`: что
означает отказ `Close` у клиента SpeechKit и стоит ли писать его в журнал.
Наблюдение понадобилось потому, что первая редакция кода и обоснования описывала
этот отказ неверно — как признак недоступности Yandex.
## Как снималось
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
`google.golang.org/grpc` версии **v1.74.2**. Смотрел два места в
`clientconn.go` — конструктор клиента и метод `Close`. К Yandex ни разу не
обратился: ни на живых ключах, ни на тестовых.
## Что выяснилось
- **`grpc.NewClient` соединения не открывает.** Клиент создаётся в состоянии
ожидания, сеть трогается при первом вызове (`clientconn.go:145`). То есть на
пути отказа конструктора — когда первый клиент создан, а второй нет — закрывать
ещё нечего.
- **`(*ClientConn).Close` возвращает ровно два исхода** (`clientconn.go:1142-1156`):
`nil` либо `ErrClientConnClosing``codes.Canceled`, «grpc: the client
connection is closing» (`clientconn.go:67`). Второй наступает **только при
повторном закрытии** уже закрытого клиента.
## Что из этого следует для нас
Отказ `Close` в этом проекте означает **нашу ошибку — закрыли дважды**, а не сбой
или недоступность Yandex. Поэтому запись в журнале при остановке процесса
адресует владельца к нашему коду; так она и сформулирована.
Обработка отказа при этом оставлена в обоих местах, хотя сегодня он практически
недостижим: она стоит одну строку и переживёт смену клиента, а её отсутствие
пришлось бы обосновывать заново каждому читателю. Решение и его цена —
[ADR](../adr/ADR-2026-08-11-errcheck-check-blank.md), обоснование целиком — в
архивном
[design.md](../../openspec/changes/archive/2026-08-11-errors-as-instead-of-typecast/design.md),
Решение 2.
**Наблюдение привязано к версии.** Сменится мажорная версия `grpc` — перечень
исходов `Close` надо перечитать, а не считать его прежним.
+140
View File
@@ -0,0 +1,140 @@
# Очередь задач: своя таблица против готовой библиотеки
Отвечает на вопрос разведки `job-queue-choice`: брать ли готовую очередь на Go
поверх той же встроенной базы или оставить свою таблицу, дописав к ней повторы,
счётчик попыток и очередь мёртвых задач. Разведка шла перед `pocketbase-storage`,
потому что смена хранилища переписывает захват задачи в любом случае.
Внешнего брокера — Redis, RabbitMQ, NATS — не рассматривали по рамке задачи: он
добавляет к выкладке процесс, которого там нет, ради нагрузки в единицы записей
в день.
## Как снималось
Дата замеров — 2026-08-11. Всё считал в каталоге вне репозитория, который
удалён вместе с песочницей; боевые данные не участвовали.
- **Цена зависимости.** Завёл пустой модуль на Go 1.24 с одним PocketBase
0.39.10, затем его копии с добавленной библиотекой. Пакеты в сборке —
`CGO_ENABLED=0 go list -deps .`, модули в графе — `go list -m all`.
- **Захват одним запросом.** Программа на 40 строк в той же песочнице: таблица
из одной строки, три горутины разом выполняют один и тот же запрос
`UPDATE … WHERE id = (SELECT … LIMIT 1) RETURNING …`. Драйвер —
`modernc.org/sqlite` v1.55.0, тот самый, которым ходит в базу PocketBase,
режим журнала WAL, таймаут занятости 5 секунд.
- **Свойства библиотек** взяты из их документации, а не замерены: пометки
«объявлено» ниже стоят именно там.
- **Холостой опрос** не мерил, а посчитал: три воркера и пауза 1 секунда из
[../database.md](../database.md), «Настройки с числовым значением», дают
3 × 86 400 = **259 200 запросов к базе в сутки** независимо от того, есть ли
работа.
## Готовой очереди для PocketBase на Go нет
Проверил по списку экосистемы `awesome-pocketbase` и по обсуждениям в
репозитории PocketBase. Единственная очередь в списке — `pocketbase-queue`,
написана на TypeScript и работает из JS-хуков; из Go её не подключить. Она
заводит три коллекции (`queue_tasks`, `queue_locks`, `queue_stats`), упавшие
задачи держит с текстом ошибки семь дней и объявляет 50–60 задач в секунду на
четырёх воркерах. Ни нарастающей паузы, ни счётчика попыток у неё нет.
Автор PocketBase в обсуждении № 2101 советует ровно свою коллекцию с полями
«имя, данные, состояние» и обход её по расписанию, а про встроенную очередь
говорит: «очередь писем, а может и общая очередь задач, есть в моих планах, но
пока приоритет низкий». Планировщик у PocketBase свой, `app.Cron()`.
Отсюда разрез сравнения: выбор идёт не между готовым и своим, а между **своим в
коллекции PocketBase** и **чужой очередью, живущей рядом с PocketBase и мимо её
панели**. River и goqite про PocketBase не знают.
## Захват чинится одним запросом
Сегодняшний захват — два запроса подряд без транзакции
([../database.md](../database.md), «Представление данных»). Замер показал, что
после перехода на PocketBase он сворачивается в один: движок за
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
горутинах разом запись получила **ровно одна**.
Это снимает главный довод в пользу чужой библиотеки: транзакционность захвата
покупается одной строкой запроса, а не новой зависимостью.
## Кандидаты
| | Своя таблица коллекцией | River 0.43.0 | goqite 0.4.0 |
| --- | --- | --- | --- |
| Пакетов в сборке сверх PocketBase | 0 | 46 | 3 |
| Модулей в графе сверх PocketBase | 0 | 18 | 7 |
| Требует CGO | нет | нет | нет |
| Видна в панели PocketBase | да, правится | нет | нет |
| Повторы с нарастающей паузой | писать | есть | нет |
| Счётчик попыток | писать | есть | есть, предел выдач |
| Очередь мёртвых задач | писать | есть, состояние «отброшена» | нет |
| Ожидание без траты попытки | писать | есть | нет |
Числа зависимостей: с одним PocketBase в сборке 122 внешних пакета и 72 модуля
в графе; с River и его драйвером SQLite — 168 и 90; с goqite и его пакетом
задач — 125 и 79. Ни в одной сборке `mattn/go-sqlite3` не участвует: у goqite он
значится в графе, но только как зависимость его собственных проверок, и при
`CGO_ENABLED=0` всё три варианта собираются.
### River
Драйвер SQLite (`riverdriver/riversqlite`) появился в версии 0.23.0 и авторами
объявлен опытным: «схема ещё может быть слегка изменена, прежде чем её сочтут
окончательной». Объявленная скорость — четверть от той, что даёт Postgres, около
10 000 задач в секунду; для единиц записей в день это запас, которым мы не
воспользуемся. Свою веб-панель River даёт встраиваемым обработчиком, отдельного
процесса она не требует.
Ложится на нашу задачу River лучше всех по одному месту: ожидание операции
SpeechKit длиной до суток выражается его отложением, и попытка при этом не
тратится. Всё остальное против:
- очередь становится **цепочкой задач вместо состояния в таблице**, а это
переписывание `internal/service`, а не хранилища;
- свои таблицы River заводит сам, и панель PocketBase их не покажет: она знает
только свои коллекции. Показать их можно коллекцией-представлением, и та
**только для чтения** — повторить мёртвую задачу из панели не выйдет;
- панелей становится две, и у второй свой вход, который тоже надо закрывать на
обратном прокси;
- документация советует пул в одно соединение, чтобы не ловить отказ по
занятости, — поверх файла, который уже держит PocketBase.
### goqite
Самая дешёвая по зависимостям и самая бедная по существу. Сообщение — двоичное
тело в одной колонке: в панели оно нечитаемо. По умолчанию срок невидимости 5
секунд и предел выдач 3; нарастающей паузы нет, очереди мёртвых задач нет —
исчерпавшее предел сообщение просто перестаёт выдаваться. Это молчаливая потеря
принятой записи, а она запрещена инвариантом «Принятая запись не теряется молча»
([../../CLAUDE.md](../../CLAUDE.md), «Инварианты»). То есть счётчик попыток и
очередь мёртвых пришлось бы дописывать и поверх goqite — ровно то, ради чего
разведка затевалась.
## Что решено и от чего отказались
Решение — **своя таблица, но коллекцией PocketBase**: захват одним запросом с
`RETURNING`, счётчик попыток колонкой, нарастающая пауза через существующий
`delay_time`, состояние «мертва» вместо `is_error = 1`. Записано в
[ADR-2026-08-11-queue-as-pocketbase-collection](../adr/ADR-2026-08-11-queue-as-pocketbase-collection.md).
Отвергнуты:
- **River с драйвером SQLite** — покупает повторы, счётчик и мёртвых готовыми, но
выносит очередь из панели PocketBase, ради которой хранилище и переезжает, и
переписывает конвейер в цепочку задач. Опытный драйвер со сменной схемой
добавляет к этому обязанность следить за чужими миграциями;
- **goqite** — не отвечает ни на один из трёх вопросов задачи целиком, а его
предел выдач молча теряет запись;
- **`pocketbase-queue`** — на TypeScript, из Go не подключается.
## Чего разведка не узнала
- **Сколько стоит написать недостающее.** Объём работы по повторам, счётчику
попыток и мёртвым задачам не оценивался: он входит в
`pocketbase-storage`, которая переписывает репозиторий целиком.
- **Ложится ли суточное ожидание операции SpeechKit на River без сюрпризов.**
Проверка стоит написания кода, а выбранному способу она не нужна вовсе.
- **Нужен ли отказ от холостого опроса.** 259 200 запросов в сутки посчитаны, а
во что они обходятся на файле базы — нет. Процесс один, и разбудить воркер
внутри него можно каналом, но задачи на это нет.
+98
View File
@@ -0,0 +1,98 @@
# PocketBase: умолчания, которые ломают штатный сценарий
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
`&core.FileField{MaxSize: 0}` читается библиотекой как её собственное умолчание:
```
core/field_file.go:28 const DefaultFileFieldMaxSize int64 = 5 << 20
core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
```
Проверено укладкой: файл в 6 МиБ отвергается на сохранении записи —
`the maximum allowed file size is 5242880 bytes`. Прогон через боевой роутер дал
границу дословно:
| тело запроса | ответ |
| --- | --- |
| 4 194 304 байта | `201` |
| 5 238 784 байта | `201` |
| 5 246 976 байт | `500` |
| 34 603 008 байт | `413` |
**Цена для сервиса:** 5 МиБ — это примерно 5,5 минут mp3 при 128 кбит/с. Отвергалась
бы не только длинная запись на приёме: результат конвертации в ogg переваливает
тот же порог примерно на пятой минуте, и **уже принятая** задача исчерпывала бы
попытки на шаге конвертации.
## Тело запроса режется на 32 МиБ раньше обработчика
`apis/base.go:36` вешает `BodyLimit(DefaultMaxBodySize)` на **корневой** роутер,
то есть и на чужие маршруты; `apis/middlewares_body_limit.go:14`
`const DefaultMaxBodySize int64 = 32 << 20`. Ответ `413` уходит мимо обработчика,
без строки в журнале приёма (последняя строка таблицы выше).
Снимается на маршруте: `.Bind(apis.BodyLimit(<своё число>))`.
## Таймаут чтения запроса — пять минут
`apis/serve.go:151` ставит `ReadTimeout: 5 * time.Minute`. Заливка шестичасовой
записи по медленному каналу его переживает: соединение рвётся на середине.
Снимается в хуке `OnServe``se.Server.ReadTimeout = 0`.
## Суффикс к имени файла дописывает конструктор, а не укладка
Первая записка наблюдала `sample.ogg → sample_uztrv6wvz3.ogg` и читала это как
свойство хранилища. Наблюдение верно **только когда имя строит сама библиотека**:
десять случайных знаков добавляет `normalizeName`, вызываемый из
`filesystem.NewFileFrom*`. Имя, положенное в поле `File.Name` после
конструктора, ложится на диск дословно:
```
задано 11111111-2222-3333-4444-555555555555.mp3
на диске 11111111-2222-3333-4444-555555555555.mp3
```
**Цена:** тот, кто задаёт имя сам, не получает от суффикса никакой
неугадываемости — и защищать ссылку на файл ему приходится другим.
## Хук правки записи не различает, кто пишет
`app.OnRecordUpdate(<коллекция>)` — событие **модели**: оно срабатывает на каждом
`app.Save`, включая сохранение из собственного кода. Хук, написанный «для
панели», правил записи конвейера: проверено прогоном — задержка, поставленная
шагом вместе со сменой состояния, обнулялась тем же сохранением.
Различает источник `app.OnRecordUpdateRequest(<коллекция>)`: оно поднимается
только на правку запросом, а код, пишущий мимо HTTP-слоя, под него не попадает.
## Приглашение завести владельца панели живёт полчаса
`apis/installer.go:31``systemSuperuser.NewStaticAuthToken(30 * time.Minute)`;
печатается только пока владельца нет (`needInstallerSuperuser`). Проверено
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет.
## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
шестичасовой записи с запасом на видео, а не замером: настоящего распределения
длин у сервиса нет.
- **Как ведёт себя укладка файла в несколько гигабайт.** Самая длинная проверенная
запись — 9,6 МБ (десять минут mp3). Потоковую укладку это подтверждает, предел
— нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой.
+113
View File
@@ -0,0 +1,113 @@
# PocketBase: что даёт панель администратора
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах.
## Как снималось
Версия **0.39.10**, выпуск от 2026-07-30 (`./pocketbase --version`). Смотрел на
пустой базе в каталоге вне репозитория, боевые данные не участвовали. Прогонов
было два:
- **готовый бинарник** — `pocketbase serve --http=127.0.0.1:8099`, суперпользователь
заведён командой `pocketbase superuser create`. Возможности панели снимал её же
запросами (`/api/collections`, `/api/logs`, `/api/backups`, `/api/crons`,
`/api/settings`) и поиском по её собранному коду;
- **своя сборка**, где PocketBase подключён библиотекой к пустому приложению на
Go, — так, как предполагает задача `pocketbase-storage`.
Оба прогона удалены вместе с песочницей.
## Правка записей — работает целиком
Панель показывает каждую коллекцию таблицей, отбирает записи своим языком
фильтров, сортирует, создаёт, правит и удаляет их по одной. Сверх таблицы в ней
есть выгрузка списка в CSV, журнал запросов с временем ответа и кодом, резервные
копии с загрузкой и восстановлением, список заданий планировщика.
Групповой операции над отмеченными записями в панели нет: удаление идёт по
одной. Проверял поиском по её коду — строк вида «удалить отмеченное» в нём не
нашлось, тогда как «Export as CSV» и «Download JSON» нашлись.
## Пользователи — только те, кого туда положат
Панель показывает свою коллекцию пользователей и ничего больше. Отсюда следствие
для целевого входа: **пользователи Authelia в панели не появятся, если вход
делает само приложение**. Пустая база заводит шесть коллекций, из них одна
пользовательская (`users`) и пять служебных, включая `_externalAuths` — связь
записи с внешним провайдером.
Второй путь есть, и он работает: **вход можно отдать самой PocketBase**. У
пользовательской коллекции настраивается провайдер `oidc` с произвольными
адресами; я включил его на адреса вида `https://auth.example.com/api/oidc/...`,
и клиент немедленно стал получать провайдера в списке способов входа. Тогда
учётные записи заводятся сами, и панель их видит.
**В саму панель Authelia не пускает.** Вход суперпользователя — своя почта и свой
пароль:
- включить `oidc` у коллекции суперпользователей не удалось: запрос принимается,
но возвращает коллекцию с выключенным `oauth2`;
- включить второй фактор у неё же не удалось тоже — ответ `403`.
Ограничить панель списком адресов можно: настройка `superuserIPs` принимает
адреса и подсети. **Ею же можно запереть себя** — после того как я поставил туда
чужой адрес, все запросы суперпользователя, включая запрос на сброс настройки,
стали отвечать `403`. Команды сброса в наборе нет: он состоит из `migrate`,
`superuser`, `update` и `serve`.
## Файлы — только свои
Файл живёт полем записи, и раскладку на диске выбирает PocketBase:
```
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg.attrs
```
Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.
*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
заданное после конструктора, ложится на диск дословно — см.
[pocketbase-defaults.md](pocketbase-defaults.md).
Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
на файл, уже лежащий на диске мимо её каталога, нет.
Поле помечается защищённым, и тогда файл не отдаётся по прямой ссылке: без токена
ответ `404`, с выданным файловым токеном — `200`.
**Резервные копии накрывают ровно её каталог.** Файлы, оставленные снаружи, в них
не попадут — то есть панель и встроенное резервное копирование покупаются одной и
той же ценой.
## Побочное: CGO уходит
Библиотечная сборка встала при `CGO_ENABLED=0` — PocketBase ходит в SQLite через
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
не замерялось.
Панель отдаётся по адресу `/_/` того же порта, что и остальное приложение, — и в
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`.
## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают.
Довод, ради которого перевод затевался, пропадает целиком.
- **Оставить вход у приложения, а PocketBase взять только хранилищем.**
Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из
трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
- **Отказаться от перевода.** Отвергнуто человеком 2026-08-11 при выборе из трёх
способов:
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
копирование, которых у сервиса-архива нет никаких.
+139
View File
@@ -0,0 +1,139 @@
# Фреймворк приложения: Svelte, Vue и React на одном экране
Отвечает на вопрос разведки `spa-framework-choice`: какой фреймворк берём под
приложение на четыре экрана, которое собирается в статику, вшивается в бинарник
через `go:embed` и ставится на телефон.
Кандидатов назвал владелец: Svelte, Vue и React, все с Vite. Мера тоже названа им
— размер собранной статики, простота вшивания и цена шага сборки в гейте, а не
популярность. Серверную отрисовку и надстройки над фреймворками — SvelteKit,
Nuxt, Next — не рассматривали: конвенция
[../conventions/web-ui.md](../conventions/web-ui.md) уже требует статику в
бинарнике, а все три надстройки по умолчанию ждут процесс Node рядом.
## Как снималось
Дата замеров — 2026-08-11. Всё считал в каталоге вне репозитория, который удалён
вместе с песочницей. Node 24.18.0, npm 11.16.0, Vite 8.2.1, TypeScript 6.0.3.
- **Каркасы** — `npm create vite@latest <имя> -- --template svelte-ts|vue-ts|react-ts`.
Из каждого удалил демонстрационные картинки и компонент-счётчик, чтобы в сборку
попал только пробный экран.
- **Пробный экран** один и тот же по смыслу: список записей, опрос состояния
незавершённых раз в две секунды, полоса ошибки, пустое состояние, разбор даты.
60 строк на Vue, 64 на Svelte, 69 на React; стили — один и тот же файл на 15
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
одинаковость экрана.
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
столько экранов у цели [web-access](../../tasks/items/web-access.md). Роутеры
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
мои.
- **Установка** — `npm ci --cache <свой пустой каталог>`; у каждого каркаса кэш
свой, иначе первый прогон скачивает общие пакеты за остальных.
- **Сборка** — `npm run build` трижды подряд с удалением `dist` и кэша Vite,
в таблице лучшее из трёх. Числа сняты на машине разработчика, не в гейте.
- **Совместимость `svelte-spa-router` со Svelte 5** взята из его описания в
реестре, а не проверена: `peerDependencies` объявляет `svelte: ^5.0.0`.
## Числа
| Мера | Svelte 5.56.8 | Vue 3.5.41 | React 19.2.8 |
| --- | --- | --- | --- |
| Пробный экран, скрипт | 35 598 Б / 13 931 Б gzip | 62 493 / 24 372 | 191 797 / 59 679 |
| Четыре маршрута с роутером | 45 053 / **17 314** | 86 890 / **33 326** | 228 750 / **72 402** |
| Стили, у всех один файл | 883 / 474 | 883 / 474 | 883 / 474 |
| Файлов в `dist/` | 3 плюс значок | то же | то же |
| Пакетов в установке, каркас | 49 | 48 | 27 |
| Пакетов с роутером | 51 | 84 (роутер 5) / 50 (роутер 4) | 29 |
| `node_modules` с роутером | 74 МБ | 92 МБ | 91 МБ |
| Установка с пустым кэшем | 7,4 с | 9,1 с | 9,4 с |
| `npm ci` с тёплым кэшем | 0,44 с | 0,41 с | 0,39 с |
| Сборка и проверка типов | 0,32 с плюс 1,16 с | 1,08 с | 0,81 с |
Пакеты считал так: имена первого уровня в `node_modules` плюс имена второго
уровня внутри областей `@…`. В `package-lock.json` записей больше — 74, 72 и 69
у каркасов, — потому что он перечисляет двоичные сборки Rollup и oxlint под все
платформы, а ставится одна.
Проверка типов у Vue и React входит в `npm run build` (`vue-tsc -b && vite build`
и `tsc -b && vite build`), у Svelte вынесена в отдельную команду `npm run check`
и в сборке не участвует — отсюда две цифры в последней строке.
## Что оказалось одинаковым и потому ничего не выбирает
- **Вшивание в бинарник.** У всех троих `dist/` — это `index.html`, один файл
скрипта и один файл стилей с хешем в имени плюс значок. Ни один не кладёт
файлов, начинающихся с точки или подчёркивания, поэтому `go:embed` берёт
каталог обычной строкой, без `all:`.
- **Node в гейте и в образе.** Шаг сборки статики нужен всем троим одинаково: на
машине разработчика, в `task gate` и слоем сборки в `Dockerfile`.
- **Установка на телефон.** `vite-plugin-pwa` 1.3.0 от фреймворка не зависит: в
его `peerDependencies` стоит Vite, и ни одного фреймворка там нет.
- **Цена шага сборки.** Секунда с небольшим у всех троих, и на фоне сборки Go и
`golangci-lint` в гейте это не различие.
Различает единственное — **размер того, что скачивает телефон**, и он расходится
вчетверо.
## Кандидаты
### Svelte
Компилятор, а не библиотека времени выполнения: в собранный файл попадает почти
только свой код, отсюда 17 314 Б на четыре экрана — вчетверо меньше React.
Реактивность и хранилище состояния встроены, третьей библиотеки под них не нужно.
Против: своего роутера у Svelte нет, а `svelte-spa-router` держит один человек.
Проверка типов идёт отдельной командой, то есть в гейте это второй шаг.
### Vue
Библиотека с официальным роутером и официальным хранилищем состояния. 33 326 Б на
четыре экрана — вдвое легче React и вдвое тяжелее Svelte. Разметка отделена от
кода однофайловым компонентом, документация переведена на русский.
Пятая версия роутера тянет в установку 34 пакета сверх четвёртой (84 против 50):
в неё встроена сборочная надстройка под маршруты по файлам. На собранный файл это
не влияет — 33 326 Б против 33 848 Б у четвёртой версии, то есть пятая даже чуть
легче, — и **надстройка не обязательна**: замер шёл на своей таблице маршрутов
через `createRouter`, ни один плагин Vite для этого не регистрировался.
Пятая версия — стабильная, а не предварительная: 5.0.0 вышла 29 января 2026,
текущая 5.2.0 — 15 июля, метка `latest` стоит на ней.
### React
Экосистема больше, чем у двух других, — числом я её не мерил, — а пакетов в
установке меньше всех: 27. Всё остальное против: 72 402 Б на четыре экрана, и ниже этого пола он не опускается, потому что
пол задаёт сама библиотека. Роутер, хранилище состояния и работа с запросами —
третьими библиотеками, каждая со своим сроком жизни.
## Что решено и от чего отказались
Решение — **Vue с роутером пятой версии**, записано в
[ADR-2026-08-11-spa-on-vue](../adr/ADR-2026-08-11-spa-on-vue.md).
Отвергнуты:
- **Svelte** — легче Vue вдвое, но своего роутера не имеет, а тот, что есть,
держит один человек. Владелец выбрал экосистему, которая переживёт проект, а не
минимальный размер: 33 КБ на телефоне не отличаются от 17 КБ на глаз, а
брошенная зависимость отличается;
- **React** — вчетверо тяжелее Svelte и вдвое тяжелее Vue, а взамен даёт
экосистему, которой приложению на четыре экрана не на что потратиться: чужих
компонентов оно не берёт, весь показ данных — список, форма загрузки и текст.
## Чего разведка не узнала
- **Как числа изменятся на настоящих экранах.** Мерил один экран и три заглушки;
загрузка файла с полосой хода, форма настроек и таблица расхода вырастут у всех
трёх. Переносится отношение, а не абсолютные значения.
- **Цену готовых наборов компонентов.** Не мерил вовсе, а именно она способна
удвоить собранный файл.
- **Во что обходится шаг сборки в образе.** Слой Node в `Dockerfile` не
собирался: время сборки образа и его вес после добавления слоя неизвестны.
Замер сделает `spa-skeleton`, которая этот слой и пишет.
- **Сколько живёт сборочная надстройка роутера пятой версии.** 34 пакета в
установке — число, а не суждение о том, как часто они ломаются.
+332
View File
@@ -0,0 +1,332 @@
# Ревью: настройка и журнал
## Как настроен конвейер
Конвейер ревью прогонялся один раз — 2026-08-11, на изменении
`fix-http-handler-tests`; его триаж лежит в
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md`.
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
ложноположительные» первым прогоном уже пользовались.
### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому.
**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`):
- отличает «задач нет» от отказа и не считает первое ошибкой;
- при отказе на середине оставляет задачу в состоянии, из которого повтор
корректен, либо переводит в `failed` осознанно;
- не теряет ссылку на файл: `job.FileID` переставляется только после того, как
запись о новом файле создана;
- повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
- различает «сервис ответил отказом» и «сервис недоступен»;
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча.
**Репозиторий SQLite** (`adapter/repo/sqlite`):
- список колонок совпадает во всех четырёх запросах файла;
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
- захват задачи не выдаёт одну строку двум вызывающим;
- ошибка драйвера транслируется в доменную у источника.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
- отсутствие программы в `PATH` отличается от отказа обработки;
- вход, пришедший от пользователя, не попадает в аргументы командной строки
неразобранным;
- пустой или частично записанный выходной файл считается отказом;
- процесс не висит вечно.
**Любой узел** — сверх свойств своего рода:
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
становится неотличим от привычного шума (журнал, запись 2026-08-10);
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал,
запись 2026-08-11);
- **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей
единственный оракул — молчание линтера, годится ровно тогда, когда линтер
краснеет на **всех** негодных реализациях; проверяется той же мутацией.
Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`,
а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком
(журнал, запись 2026-08-11 про недостижимую норму; закрыто
[решением](adr/ADR-2026-08-11-errcheck-check-blank.md));
- **требование без сценария не имеет оракула** и потому не может быть нарушено
заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится
тем вернее, чем убедительнее написана (журнал, запись 2026-08-11).
### Типовые ложноположительные
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
логирует его и не считает в метрику. Норма записана требованием
[pipeline](../openspec/specs/pipeline/spec.md).
**Оговорка, и она тут главная:** ложноположительным считается только само
молчание воркера. Проверка **формы** узнавания ложноположительной не является:
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.
Механика захвата и её слабые места — [database.md](database.md),
«Представление данных». Находка становится настоящей ровно тогда, когда
появится второй экземпляр процесса или второй воркер на то же состояние.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
[security.md](security.md). Находкой считается только новая поверхность,
выставленная наружу, а не повторение этого факта.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`.
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
`transcribe.go`, 2026-08-10).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
2026-08-10).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
URL с токеном бота (запрет в [security.md](security.md) и
[conventions/logging.md](conventions/logging.md)).
- `security`: не строится ли путь на диске или ключ объекта из значения,
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
(чтение `service/transcribe.go`, 2026-08-10).
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены две capability (`openspec/specs/intake` и `openspec/specs/pipeline`),
и каждая описана частично. Поведение прочих узлов живёт в обзоре под маркерами
долга, а соблазн дописать туда ещё — самый большой.
- `conventions`: новая колонка правится во всех четырёх местах репозитория
(CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
тестов два файла, и оба мимо конвейера.
### Триггеры метки
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
**Крупное здесь** (поднимает до `large`, ось объёма):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь приложения, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается
по ходу, пока конвенция веб-UI пуста;
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
до работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
форма решения зависит от замера;
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
- всё, что требует записи в `research/` прежде, чем начать.
**Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`;
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не `small`.
### Недоступно проверке
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md).
## Журнал дефектов
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая —
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя.
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
её требует PocketBase, — а `Dockerfile` продолжал собирать на `golang:1.24-alpine`
с `GOTOOLCHAIN=local`. `task image` упал бы на шаге сборки: выкладки задачи
`pocketbase-storage` не существовало бы вовсе.
**Почему не поймали.** Все шесть проходов ревью и весь гейт видели зелёное:
`go build ./...` идёт на хостовом Go, а образ не собирает **ни один шаг гейта**.
Расхождение выглядело согласованным ещё и потому, что `CLAUDE.md` и `README.md`
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
были именно они.
Нашлось не проходом, а триажем — при проверке чужих починок на месте, когда он
собрал образ руками. То есть поймано случайным свойством прогона, а не
устройством конвейера: проверь триаж починки чтением, дефект уехал бы в мердж.
**Чем чинится на будущее.** Сборка образа гейтом не проверяется намеренно —
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в `Dockerfile` с
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
об отказе шага
- **Симптом:** требование гласило «отказ MUST быть записан **ровно один раз**
единственной логирующей точкой». Сервис пишет дважды — сначала шаг конвейера,
следом воркер, — то есть норма не выполнялась бы с первого дня, а после
архивации стала бы посылкой для следующих задач
- **Причина:** дефект родился при починке соседнего. Первая редакция назначала
логирующей точкой воркера и фиксировала уровень `ERROR`, чем закрепляла
контрактом долг `conventions/logging.md`. Правка по этой находке ушла в
противоположную крайность: вместо «норма молчит о числе записей» получилось
«норма требует одной». Двойная запись — записанный системный долг, и обе
редакции с ним расходились, только в разные стороны
- **Чем воспроизведён:** прогоном пробы через `go test -overlay`: один отказ
хранилища даёт две записи — `Failed to find and acquire job` из шага и
`Worker error` из воркера
- **Почему не поймали раньше:** требование не имело сценария, а значит и оракула
— упасть ему было нечем. Ревью дизайна абзац читало, но код с ним не сверяло:
кода тогда не существовало. Поймал проход `specs` на ревью кода, направлением
`code → spec`, и поймал прогоном, а не чтением
- **Что меняем:** норма говорит только проверяемое сегодня — отказ виден
владельцу и засчитан в счётчик; число записей и уровень названы долгом с
адресом. В критерии приёмки добавлена строка: норма не объявляет обязательным
недостижимое — ни в ту, ни в другую сторону
## 2026-08-11 — хвост имени отправителя уезжал на открытую страницу метрик [пойман ревью]
- **Где:** `internal/service/transcribe.go`, метки `file_extension` у размера
принятой записи и `source_format` у длительности конвертации
- **Симптом:** имя `запись.тайное-слово` клало `тайное-слово` меткой метрики, а
`GET /metrics` отдаётся без проверки отправителя. Тем же каналом множеством значений
метки распоряжался анонимный отправитель
- **Причина:** расширение берётся из имени отправителя дословно (`filepath.Ext`)
и употреблялось меткой без приведения. Канал старше задачи, которая его нашла
- **Чем воспроизведён:** прогон `filepath.Ext` на именах вида
`запись.тайное-слово`, `Разговор с Петровым 11.08`, затем чтение реестра
метрик после приёма — метка несла хвост дословно
- **Почему не поймали:** метрику никто не считал выходом приватного значения.
Тема `security` смотрела журнал, ответ и пути на диске; вопроса про метку в
перечне вопросов не было, и ни один проход её не открывал. Поймали три прохода
разом на задаче, которая закрывала соседний канал
- **Что меняем:** вопрос про метку добавлен в «Вопросы по темам»; правило
приведения нормировано спекой `intake` и записано
[решением](adr/ADR-2026-08-11-known-format-label.md)
## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]
- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
- **Симптом:** тест не поймал ни одного настоящего дефекта приёма, хотя был
зелёным и выглядел содержательным
- **Причина:** две штуки одного рода. Тест разбирал ответ в
`CreateTranscribeJobResponse` — ту самую структуру, чьи теги `json` и
составляют публичный контракт: переименование тега меняло и проверяемое, и
ожидаемое разом. И заведение задачи тест подтверждал только эхом ответа, а не
чтением базы
- **Чем воспроизведён:** мутацией. Замена тега на `json:"jobId"` и удаление
`s.jobRepo.Create(job)` из `internal/service/transcribe.go` — тесты в обоих
случаях оставались зелёными; после правки обе мутации их роняют
- **Почему не поймали:** проверки писались тем же заходом, что и правились, а
«зелено» на новом тесте читается как подтверждение. Поймал проход `specs`
ревью кода, и поймал ровно тем, что добыл оракул мутацией, а не рассуждением
- **Что меняем:** успех судится по сырому JSON и по строке в базе. В типовые
узлы, «Любой узел», добавлено свойство «проверка способна упасть» с указанием
на мутацию как способ его проверить
## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
- **Где:** `internal/controller/http/transcribe_test.go`
- **Симптом:** `go test ./...` падает четырьмя случаями; обнаружено первым же
прогоном гейта при заведении канона
- **Причина:** тест требует `testdata/sample.m4a`, которого в репозитории нет и
не могло быть — `.gitignore` содержит `*.m4a`. Остальные случаи записывают в
файл строку `test audio content` и ждут `201`, а обработчик зовёт настоящий
`ffprobe`, который такой вход отвергает
- **Чем воспроизведён:** `go test ./internal/controller/http/` — четыре отказа,
из них один по отсутствию файла и три по коду `500` вместо `201`
- **Почему не поймали:** гейта не было вовсе, а `go test` руками, судя по
результату, не гоняли ни разу с коммита `87d8b05`
- **Что меняем:** заведена задача `http-handler-tests-never-green`; в гейт
добавлен шаг `go test ./...`, и красный тест теперь виден. Настоящий остаток
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
- **Закрыт** 2026-08-11: проверки переписаны, `go test ./...` зелёный и из
списка объявленных долгов в [CLAUDE.md](../CLAUDE.md) снят
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
- **Где:** `internal/service/transcribe.go`, ветка завершения задачи
- **Симптом:** пользователь Telegram получал пустое сообщение вместо текста
- **Причина:** SpeechKit возвращал операцию успешной, но с пустым текстом, и
задача завершалась этим пустым значением
- **Чем воспроизведён:** восстановлено по коммиту `ec637c0`, оракула нет
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — пустой текст подменяется фразой «на записи нет
текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего
сервиса не превращается в успех молча» вынесено в типовой узел «клиент
внешнего сервиса» выше
## 2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]
- **Где:** `internal/adapter/telegram/sender.go`
- **Симптом:** отправка текста длиннее предела сообщения Telegram завершалась ошибкой
целиком, пользователь не получал ничего
- **Причина:** предел длины сообщения на стороне Telegram не учитывался
- **Чем воспроизведён:** восстановлено по коммиту `822e168`, который тем же
заходом завёл `internal/adapter/telegram/split_test.go`
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — деление по словам с пределом 4000 символов,
число записано в [database.md](database.md)
+258
View File
@@ -0,0 +1,258 @@
# Модель угроз
## Периметр
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
этого — сегодняшнего — периметра.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный
вход для программ по личным токенам, два уровня доступа — пользователь видит
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут;
описанное ниже разграничение доступа относится только к Telegram.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
запросов и не ограничен по размеру файла.
## Недоверенный вход
Что приходит извне и каким каналом.
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета |
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей
задачей, и до неё его нет:
| Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
| Хеш-сумма файла | Поле запроса приёма | Отправитель — и она же решает, отдать ли прежнюю запись | `dedup-by-content-hash` |
Ответ языковой модели опаснее прочего в этом списке: он приходит текстом, идёт
в заголовок записи и оттуда на экран — то есть внешний сервис пишет то, что
увидит человек.
## Куда уходит содержимое записи
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в
Telegram отправителю.
Целевой периметр добавляет три пути, каждый — своей задачей:
| Куда | Что уходит | Чья задача |
| --- | --- | --- |
| Языковая модель за шлюзом bifrost | Текст расшифровки целиком | `llm-insights-adapter`, затем `literary-text-level` |
| Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | `ntfy-delivery` |
| Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | `email-notification` |
Каждая из названных задач обязана оставить строку в этом разделе — там это
записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст
остаётся в приложении.
## Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** выбирает хранилище:
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис**
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
отправителя, не применяется: имя отправителя в хранилище не попадает.
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; проверено прогоном — записи отдают
`403`, служебные разделы `401`.
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска
прежней записи. Ищется она **в пределах одного пользователя**: глобальный
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
идентификатора задачи: имя, данное пользователем, в него не попадает.
## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с
остальным.
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
анонимен, знание UUID задачи и есть право её читать.
Целевой периметр заводит четыре механизма вместо одного белого списка:
| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
писать боту выводится из учётной записи (`telegram-account-link`).
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей** — сам по себе перечень имён.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
секрета, которого сегодня в проекте нет вовсе:
1. **Токены пользователей** (`api-tokens`). Токен даёт права своего владельца
целиком. Срока жизни у него нет. В базе лежит только отпечаток, полное
значение показывается один раз при выпуске. Это первый секрет, который
хранится **в базе**, а не в конфигурации.
2. **Ключ языковой модели** и адрес шлюза bifrost (`llm-insights-adapter`).
Утечка оплачивается деньгами.
3. **Пароль почтового сервера** (`email-notification`).
4. **Адрес почты пользователя** — приходит от Authelia и хранится у нас
(`oidc-login`, `email-notification`).
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
пользователей разом, то есть стоит вровень с самым чувствительным из списка
выше. Второй секрет после токенов пользователей, который лежит **не в
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на
успешном пути и на пути отказа — они ищут значение, а не имя поля.
**Хвост после последней точки остаётся в журнале, и это объявленное изъятие**
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
файла объявлен необратимым и меняется решением человека.
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
`transcriber_input_file_size_bytes`, `source_format` у
`transcriber_conversion_duration_seconds`) несут расширение, только приведённое к
закрытому перечню известных форматов; всё прочее заменяется значением `other`.
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
типа файла не идёт.
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
нигде не логируется.
## Что вне модели
Перечислить явно.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
SpeechKit не рассматривается.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не
замер: `research/` пуст, потолок длины стоит открытым вопросом
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
Authelia. Рост каталога данных при этом ничем не наблюдается —
открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
внешней программе, своей песочницы вокруг неё нет.
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
деньги потрачены, а строки потребления текста не содержат.
+35 -37
View File
@@ -1,6 +1,6 @@
module git.vakhrushev.me/av/transcriber module git.vakhrushev.me/av/transcriber
go 1.24.5 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
@@ -9,21 +9,19 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 github.com/aws/aws-sdk-go-v2/credentials v1.18.3
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
github.com/doug-martin/goqu/v9 v9.19.0
github.com/gin-gonic/gin v1.10.1
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.31 github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.24.3 github.com/pocketbase/pocketbase v0.39.10
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.23.0
github.com/samber/slog-gin v1.15.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.0 github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.74.2
) )
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
@@ -37,47 +35,47 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
github.com/aws/smithy-go v1.22.5 // indirect github.com/aws/smithy-go v1.27.7 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/spf13/cast v1.10.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect github.com/spf13/cobra v1.10.2 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect github.com/spf13/pflag v1.0.10 // indirect
go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/image v0.44.0 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
) )
+106 -111
View File
@@ -1,7 +1,10 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
@@ -40,99 +43,82 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58= github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM=
github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -145,28 +131,18 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM= github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
@@ -183,32 +159,33 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
@@ -220,16 +197,34 @@ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+12 -1
View File
@@ -2,6 +2,7 @@ package yandex
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
) )
type s3Config struct { type s3Config struct {
@@ -72,7 +74,16 @@ func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
Body: file, Body: file,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err) // Отказ SDK несёт полный URL объекта, то есть имя файла в хранилище, а
// оно — последняя часть ссылки на скачивание: цепочка `%w` уехала бы в
// журнал вместе с ключом. Наружу идёт класс отказа и только он — по
// нему «ключи отозваны» отличимо от «бакета нет» и от «сети нет», а
// адреса в коде отказа SDK не бывает.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return fmt.Errorf("failed to upload file to S3: %s", apiErr.ErrorCode())
}
return errors.New("failed to upload file to S3")
} }
return nil return nil
@@ -2,6 +2,7 @@ package yandex
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -52,8 +53,16 @@ func newSpeechKitService(cfg speechKitConfig) (*speechKitService, error) {
// Создаем защищенное соединение для Operations API // Создаем защищенное соединение для Operations API
opConn, err := grpc.NewClient(OperationEndpoint, grpc.WithTransportCredentials(creds)) opConn, err := grpc.NewClient(OperationEndpoint, grpc.WithTransportCredentials(creds))
if err != nil { if err != nil {
sttConn.Close() // Отказы независимы, и второй не теряется. На сегодняшнем клиенте он
return nil, fmt.Errorf("failed to connect to Operations API: %w", err) // почти наверняка не наступит: grpc.NewClient ленив, соединение к этому
// моменту не открыто, и Close вернёт отказ только при повторном
// закрытии — то есть сообщит о нашей ошибке, а не о Yandex. Сборка
// оставлена как защита от смены реализации клиента; nil от закрытия
// errors.Join отбрасывает, и форма ошибки в обычном случае не меняется.
return nil, errors.Join(
fmt.Errorf("failed to connect to Operations API: %w", err),
sttConn.Close(),
)
} }
sttClient := stt.NewAsyncRecognizerClient(sttConn) sttClient := stt.NewAsyncRecognizerClient(sttConn)
@@ -77,10 +86,10 @@ func (s *speechKitService) Close() error {
if s.opConn != nil { if s.opConn != nil {
err2 = s.opConn.Close() err2 = s.opConn.Close()
} }
if err1 != nil { // Отказы двух соединений независимы, и вернуть только первый — значит
return err1 // потерять половину причины: журнал пишется при остановке процесса, и
} // восстановить утраченное будет уже негде.
return err2 return errors.Join(err1, err2)
} }
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3 // recognizeFileFromS3 запускает асинхронное распознавание файла из S3
+56
View File
@@ -0,0 +1,56 @@
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
//
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
// контракт запуска.
package pocketbase
import (
"fmt"
pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
)
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
// шаги схемы.
//
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
func New(dataDir string) (*pb.PocketBase, error) {
app := pb.NewWithConfig(pb.Config{
DefaultDataDir: dataDir,
HideStartBanner: true,
})
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
}
if err := app.RunAllMigrations(); err != nil {
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
}
return app, nil
}
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
// потому что Serve накатывает схему прежде, чем поднять сервер.
func findCollection(app core.App, name string) (*core.Collection, error) {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
}
return collection, nil
}
@@ -0,0 +1,269 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
// системы, а не в каталоге данных: последний смонтирован на сервере, и
// временному там не место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
type FileRepository struct {
app core.App
}
func NewFileRepository(app core.App) *FileRepository {
return &FileRepository{app: app}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
work, err := newWorkFile(filepath.Ext(name))
if err != nil {
return nil, err
}
src, err := repo.openStored(record, name)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer src.Close()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
if err != nil {
return nil, err
}
stored, err := filesystem.NewFileFromPath(work.Path())
if err != nil {
return nil, fmt.Errorf("failed to read work file: %w", err)
}
stored.Name = name
record := core.NewRecord(collection)
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
// ключ при чтении.
return nil, errors.New("failed to store file")
}
return recordToFile(record), nil
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Set("location", entity.LocationS3)
record.Set("object_key", objectKey)
record.Set("size", size)
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store remote file record: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(FilesCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
return repo.openStored(record, name)
}
// openStored открывает содержимое файла в хранилище потоком.
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
fsys, err := repo.app.NewFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
}
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
if err != nil {
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
// стала бы там бессрочным ключом к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to read stored file of record %s", record.Id),
fsys.Close(),
)
}
return &storedReader{reader: reader, fsys: fsys}, nil
}
// storedReader держит открытой файловую систему хранилища на всё время чтения:
// закрытая раньше времени, она обрывает поток на середине записи.
type storedReader struct {
reader io.ReadCloser
fsys io.Closer
}
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *storedReader) Close() error {
readerErr := r.reader.Close()
fsysErr := r.fsys.Close()
switch {
case readerErr != nil && fsysErr != nil:
return errors.New("failed to close stored file and its filesystem")
case readerErr != nil:
return errors.New("failed to close stored file")
default:
return fsysErr
}
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
func firstFileName(record *core.Record) string {
names := record.GetStringSlice("file")
if len(names) == 0 {
return ""
}
return names[0]
}
func recordToFile(record *core.Record) *entity.File {
name := firstFileName(record)
if name == "" {
name = record.GetString("object_key")
}
return &entity.File{
Id: record.Id,
Location: record.GetString("location"),
FileName: name,
Size: int64(record.GetInt("size")),
CreatedAt: record.GetDateTime("created").Time(),
}
}
@@ -0,0 +1,38 @@
package pocketbase
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
// иначе прошло бы молча.
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
app := newTestApp(t)
repo := NewFileRepository(app)
const libraryDefault = 5 << 20
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
size, err := work.Size()
require.NoError(t, err)
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
file, err := repo.CreateLocal("big.mp3", work)
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
assert.Equal(t, size, file.Size)
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
}
@@ -0,0 +1,203 @@
package pocketbase
import (
"database/sql"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
// расхождение проявлялось как потерянное при сохранении поле.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
// каким входом пришла запись, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
// правка на месте.
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
record.Set("state", job.State)
record.Set("file", derefString(job.FileID))
record.Set("error_text", derefString(job.ErrorText))
record.Set("acquisition_id", derefString(job.AcquisitionID))
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
record.Set("delay_time", dateOrEmpty(job.DelayTime))
record.Set("attempts", job.Attempts)
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
record.Set("transcription_text", derefString(job.TranscriptionText))
}
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
// поля здесь не с кем.
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
applyOwnedByPipeline(record, job)
record.Set("source", job.Source)
record.Set("tg_chat_id", derefInt64(job.TgChatId))
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
}
func recordToJob(record *core.Record) *entity.TranscribeJob {
return &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
Source: record.GetString("source"),
FileID: nilIfEmpty(record.GetString("file")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
// целиком.
type acquiredRow struct {
Id string `db:"id"`
State string `db:"state"`
Source string `db:"source"`
FileID sql.NullString `db:"file"`
ErrorText sql.NullString `db:"error_text"`
AcquisitionID sql.NullString `db:"acquisition_id"`
AcquireTime sql.NullString `db:"acquire_time"`
DelayTime sql.NullString `db:"delay_time"`
Attempts int `db:"attempts"`
RecognitionOpID sql.NullString `db:"recognition_op_id"`
TranscriptionText sql.NullString `db:"transcription_text"`
TgChatId sql.NullInt64 `db:"tg_chat_id"`
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
Created sql.NullString `db:"created"`
Updated sql.NullString `db:"updated"`
}
func (r *acquiredRow) toJob() *entity.TranscribeJob {
job := &entity.TranscribeJob{
Id: r.Id,
State: r.State,
Source: r.Source,
FileID: nullToPtr(r.FileID),
ErrorText: nullToPtr(r.ErrorText),
AcquisitionID: nullToPtr(r.AcquisitionID),
AcquireTime: parseTimeOrNil(r.AcquireTime),
DelayTime: parseTimeOrNil(r.DelayTime),
Attempts: r.Attempts,
RecognitionOpID: nullToPtr(r.RecognitionOpID),
TranscriptionText: nullToPtr(r.TranscriptionText),
}
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
chatId := r.TgChatId.Int64
job.TgChatId = &chatId
}
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
msgId := int(r.TgReplyMessageId.Int64)
job.TgReplyMessageId = &msgId
}
if created := parseTimeOrNil(r.Created); created != nil {
job.CreatedAt = *created
}
if updated := parseTimeOrNil(r.Updated); updated != nil {
job.UpdatedAt = *updated
}
return job
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
func nullToPtr(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
s := v.String
return &s
}
func parseTimeOrNil(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
date, err := types.ParseDateTime(v.String)
if err != nil || date.IsZero() {
return nil
}
t := date.Time()
return &t
}
@@ -0,0 +1,122 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по имени файла шага.
//
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
}
func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection)
files.Fields.Add(
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
// готовности задачи, а не ниже.
//
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
// минут. Число выведено из расчётного потолка записи в шесть часов с
// запасом на видео; оно же стоит строкой в docs/database.md.
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
// Где лежит копия. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
// разным вещам.
&core.SelectField{
Name: "location",
Values: []string{entity.LocationLocal, entity.LocationS3},
MaxSelect: 1,
Required: true,
},
// Ключ объекта во внешнем хранилище; у местной копии пуст.
&core.TextField{Name: "object_key"},
&core.NumberField{Name: "size", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(files); err != nil {
return err
}
jobs := core.NewBaseCollection(JobsCollection)
jobs.Fields.Add(
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
// не должна попасть в выборку с состоянием, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: []string{
entity.StateCreated,
entity.StateConverted,
entity.StateTranscribe,
entity.StateDone,
entity.StateFailed,
entity.StateDead,
},
MaxSelect: 1,
Required: true,
},
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
// результат.
// Обязательна: задача без записи не может пройти ни одного шага, и
// заведённая в панели руками она дошла бы до шага только затем, чтобы
// отказать. Компилятор этого не держит — держит схема.
&core.RelationField{
Name: "file",
CollectionId: files.Id,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "error_text"},
&core.TextField{Name: "acquisition_id"},
&core.DateField{Name: "acquire_time"},
&core.DateField{Name: "delay_time"},
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
// завершившемся без отказа.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.TextField{Name: "recognition_op_id"},
&core.EditorField{Name: "transcription_text"},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
// состоянию снимает полный перебор, который был у прежней таблицы.
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
return app.Save(jobs)
}
func down202608110001(app core.App) error {
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return err
}
}
return nil
}
func ptr[T any](v T) *T { return &v }
+36
View File
@@ -0,0 +1,36 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
)
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
// и правку из кода.
//
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
// от первого же отказа). Узнать об этом ему неоткуда.
//
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next()
}
e.Record.Set("acquisition_id", "")
e.Record.Set("acquire_time", "")
e.Record.Set("delay_time", "")
e.Record.Set("attempts", 0)
return e.Next()
})
}
@@ -0,0 +1,147 @@
package pocketbase
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type TranscriptJobRepository struct {
app core.App
}
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
return &TranscriptJobRepository{app: app}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, JobsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
if job.Id != "" {
record.Id = job.Id
}
applyToRecord(record, job)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
job.Id = record.Id
job.CreatedAt = record.GetDateTime("created").Time()
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
}
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
// одной транзакцией: шаг, потерявший задачу за время работы, получает
// LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(JobsCollection, job.Id)
if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: job.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, job)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
if err != nil {
return err
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
record, err := repo.app.FindRecordById(JobsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
`tg_chat_id, tg_reply_message_id, created, updated`
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
// пометка её захваченной идут вместе, и захваченная возвращается тем же
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
now := types.NowDateTime()
query := repo.app.DB().NewQuery(`
UPDATE {{` + JobsCollection + `}}
SET acquisition_id = {:acquisition_id},
acquire_time = {:now},
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM {{` + JobsCollection + `}}
WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
ORDER BY created, id
LIMIT 1
)
RETURNING ` + acquireColumns)
rotting, err := types.ParseDateTime(rottingTime)
if err != nil {
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
}
query.Bind(dbx.Params{
"acquisition_id": acquisitionId,
"now": now.String(),
"state": state,
"rotting": rotting.String(),
})
var row acquiredRow
if err := query.One(&row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
}
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
}
return row.toJob(), nil
}
@@ -0,0 +1,410 @@
package pocketbase
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
// путём, каким это делает сервис при старте.
func newTestApp(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
func newFile(t *testing.T, app core.App) *entity.File {
t.Helper()
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
require.NoError(t, err)
return file
}
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
t.Helper()
file := newFile(t, repo.app)
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
require.NoError(t, repo.Create(job))
return job
}
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
const racers = 3
var (
wg sync.WaitGroup
mu sync.Mutex
got []*entity.TranscribeJob
notFound int
)
start := make(chan struct{})
for i := 0; i < racers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
<-start
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
mu.Lock()
defer mu.Unlock()
if err != nil {
var missing *contract.JobNotFoundError
if assert.ErrorAs(t, err, &missing) {
notFound++
}
return
}
got = append(got, acquired)
}(i)
}
close(start)
wg.Wait()
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
assert.Equal(t, job.Id, got[0].Id)
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
}
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, first)
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
}
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
// собственные времена хранилища.
record, err := app.FindRecordById(JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record))
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
assert.Equal(t, job.Id, again.Id)
}
// Пауза держит задачу от выдачи, пока не кончится.
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
delay := time.Now().Add(time.Hour)
job.DelayTime = &delay
require.NoError(t, repo.Save(job, ""))
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
}
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
// задаче, брошенной вместе с процессом.
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
for expected := 1; expected <= 3; expected++ {
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
assert.Equal(t, expected, acquired.Attempts)
}
}
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
// потерянное поле.
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
chatId := int64(4242)
replyId := 17
opId := "operation-id"
text := "расшифровка"
file := newFile(t, app)
job := &entity.TranscribeJob{
State: entity.StateTranscribe,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
TgReplyMessageId: &replyId,
RecognitionOpID: &opId,
TranscriptionText: &text,
}
require.NoError(t, repo.Create(job))
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, acquired.Id)
assert.Equal(t, entity.StateTranscribe, acquired.State)
assert.Equal(t, entity.SourceTelegram, acquired.Source)
require.NotNil(t, acquired.TgChatId)
assert.Equal(t, chatId, *acquired.TgChatId)
require.NotNil(t, acquired.TgReplyMessageId)
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
require.NotNil(t, acquired.RecognitionOpID)
assert.Equal(t, opId, *acquired.RecognitionOpID)
require.NotNil(t, acquired.TranscriptionText)
assert.Equal(t, text, *acquired.TranscriptionText)
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
}
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(JobsCollection, mine.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record))
mine.MoveToState(entity.StateConverted)
err = repo.Save(mine, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
// И состояние не поехало.
after, err := repo.GetByID(mine.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
}
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
job.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(job, ""))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State)
}
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
// захвату и умирает от первого же отказа, и не узнал бы об этом.
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(JobsCollection, job.Id)
require.NoError(t, err)
record.Set("attempts", 5)
record.Set("state", entity.StateDead)
require.NoError(t, app.Save(record))
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
// запросом к записи, а не сохранением из кода.
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
assert.Nil(t, after.AcquireTime, "время захвата снято")
assert.Nil(t, after.DelayTime, "пауза снята")
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
// И ближайший захват задачу выдаёт.
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, again.Id)
}
// Обратная сторона того же правила, и она дороже: правила панели MUST не
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateConverted)
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Шаг ставит задержку опроса вместе со сменой состояния.
delay := time.Now().Add(10 * time.Second)
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
// сколько раз мы пробовали.
after.Attempts = 6
after.Die("attempts exhausted: 6")
require.NoError(t, repo.Save(after, ""))
dead, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, dead.State)
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
}
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
// API от имени владельца.
func patchRecord(t *testing.T, app core.App, recordID, body string) {
t.Helper()
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
owner := core.NewRecord(superusers)
owner.Set("email", "owner@example.com")
owner.Set("password", "ownerpassword123")
require.NoError(t, app.Save(owner))
token, err := owner.NewStaticAuthToken(time.Hour)
require.NoError(t, err)
router, err := apis.NewRouter(app)
require.NoError(t, err)
mux, err := router.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPatch,
"/api/collections/"+JobsCollection+"/records/"+recordID,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
}
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
// отказа в панели.
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
file := newFile(t, app)
chatId := int64(111)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
}
require.NoError(t, repo.Create(job))
// Шаг захватил задачу и работает.
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Владелец правит в панели поле, которого конвейер не касается.
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
// Шаг доработал и сохраняет свой снимок.
acquired.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
require.NotNil(t, after.TgChatId)
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
}
-56
View File
@@ -1,56 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/doug-martin/goqu/v9"
)
type FileRepository struct {
db *sql.DB
gq *goqu.Database
}
func NewFileRepository(conn *sql.DB, gq *goqu.Database) *FileRepository {
return &FileRepository{conn, gq}
}
func (repo *FileRepository) Create(file *entity.File) error {
record := goqu.Record{
"id": file.Id,
"storage": file.Storage,
"file_name": file.FileName,
"size": file.Size,
"created_at": file.CreatedAt,
}
query := repo.gq.Insert("files").Rows(record)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
return nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
query := repo.gq.From("files").Select("id", "storage", "file_name", "size", "created_at").Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var file entity.File
err = repo.db.QueryRow(sql, args...).Scan(&file.Id, &file.Storage, &file.FileName, &file.Size, &file.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return &file, nil
}
@@ -1,230 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
goqu "github.com/doug-martin/goqu/v9"
)
type TranscriptJobRepository struct {
db *sql.DB
gq *goqu.Database
}
func NewTranscriptJobRepository(db *sql.DB, gq *goqu.Database) *TranscriptJobRepository {
return &TranscriptJobRepository{db, gq}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
record := goqu.Record{
"id": job.Id,
"state": job.State,
"source": job.Source,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"tg_chat_id": job.TgChatId,
"tg_reply_message_id": job.TgReplyMessageId,
"created_at": job.CreatedAt,
"updated_at": job.UpdatedAt,
}
query := repo.gq.Insert("transcribe_jobs").Rows(record)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
return nil
}
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob) error {
record := goqu.Record{
"state": job.State,
"source": job.Source,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"tg_chat_id": job.TgChatId,
"tg_reply_message_id": job.TgReplyMessageId,
"updated_at": job.UpdatedAt,
}
query := repo.gq.Update("transcribe_jobs").Set(record).Where(goqu.C("id").Eq(job.Id))
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
query := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"source",
"file_id",
"is_error",
"error_text",
"acquisition_id",
"acquire_time",
"delay_time",
"recognition_op_id",
"transcription_text",
"tg_chat_id",
"tg_reply_message_id",
"created_at",
"updated_at",
).Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var job entity.TranscribeJob
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.Source,
&job.FileID,
&job.IsError,
&job.ErrorText,
&job.AcquisitionID,
&job.AcquireTime,
&job.DelayTime,
&job.RecognitionOpID,
&job.TranscriptionText,
&job.TgChatId,
&job.TgReplyMessageId,
&job.CreatedAt,
&job.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return &job, nil
}
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
updateQuery := repo.gq.Update("transcribe_jobs").
Set(
goqu.Record{
"acquisition_id": acquisitionId,
"acquire_time": time.Now(),
},
).
Where(
goqu.C("id").Eq(
repo.gq.From("transcribe_jobs").Select("id").
Where(
goqu.And(
goqu.C("state").Eq(state),
goqu.C("is_error").Eq(0),
goqu.Or(
goqu.C("delay_time").IsNull(),
goqu.C("delay_time").Lt(time.Now()),
),
goqu.Or(
goqu.C("acquisition_id").IsNull(),
goqu.C("acquire_time").Lt(rottingTime),
),
),
).
Limit(1),
),
)
sql, args, err := updateQuery.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
// log.Printf("aquire sql: %s", sql)
result, err := repo.db.Exec(sql, args...)
if err != nil {
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("failed check affected rows: %w", err)
}
if rowsAffected == 0 {
e := contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
return nil, &e
}
if rowsAffected != 1 {
return nil, fmt.Errorf("unexpected affected rows count: %d", rowsAffected)
}
selectQuery := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"source",
"file_id",
"is_error",
"error_text",
"acquisition_id",
"acquire_time",
"delay_time",
"recognition_op_id",
"transcription_text",
"tg_chat_id",
"tg_reply_message_id",
"created_at",
"updated_at",
).Where(goqu.C("acquisition_id").Eq(acquisitionId))
sql, args, err = selectQuery.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var job entity.TranscribeJob
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.Source,
&job.FileID,
&job.IsError,
&job.ErrorText,
&job.AcquisitionID,
&job.AcquireTime,
&job.DelayTime,
&job.RecognitionOpID,
&job.TranscriptionText,
&job.TgChatId,
&job.TgReplyMessageId,
&job.CreatedAt,
&job.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return &job, nil
}
+4 -10
View File
@@ -9,7 +9,6 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Yandex YandexConfig `toml:"yandex"` Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"` Telegram TelegramConfig `toml:"telegram"`
@@ -22,12 +21,10 @@ type ServerConfig struct {
UsersWhiteList []string `toml:"users_while_list"` UsersWhiteList []string `toml:"users_while_list"`
} }
type DatabaseConfig struct { // StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
Path string `toml:"path"` // записей. Двух путей, как было раньше, у хранилища не бывает.
}
type StorageConfig struct { type StorageConfig struct {
Path string `toml:"path"` DataDir string `toml:"data_dir"`
} }
type YandexConfig struct { type YandexConfig struct {
@@ -53,11 +50,8 @@ func defaultConfig() *Config {
ShutdownTimeout: 5, ShutdownTimeout: 5,
ForceShutdownTimeout: 20, ForceShutdownTimeout: 20,
}, },
Database: DatabaseConfig{
Path: "data/transcriber.db",
},
Storage: StorageConfig{ Storage: StorageConfig{
Path: "data/files", DataDir: "data",
}, },
Yandex: YandexConfig{ Yandex: YandexConfig{
FolderID: "", FolderID: "",
+12
View File
@@ -11,6 +11,18 @@ func (e *JobNotFoundError) Error() string {
return fmt.Sprintf("%s - %s", e.State, e.Message) return fmt.Sprintf("%s - %s", e.State, e.Message)
} }
// LostAcquisitionError — захват задачи за время работы шага достался другому.
// Шаг, получивший его, завершается без записи результата и без ответа
// отправителю: иначе два воркера пишут в одну задачу по очереди, а отправитель
// получает два ответа на одну запись.
type LostAcquisitionError struct {
JobID string
}
func (e *LostAcquisitionError) Error() string {
return fmt.Sprintf("%s: job acquisition lost", e.JobID)
}
type NoopJobError struct { type NoopJobError struct {
State string State string
} }
+41 -2
View File
@@ -1,19 +1,58 @@
package contract package contract
import ( import (
"io"
"time" "time"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// WorkFile — рабочая копия файла на диске: её просят шаги, отдающие файл
// внешней программе, потому что `ffmpeg` и `ffprobe` принимают имя аргументом.
//
// Заводится копия одним способом — репозиторием файлов, — и убирает её за собой
// Close. Каждый шаг, заводящий копию сам, повторял бы и обязанность прибрать, а
// забытая копия это шестичасовая запись во временном каталоге, о которой не
// узнает никто.
type WorkFile interface {
// Path — имя копии на диске, годное для внешней программы.
Path() string
// Size — длина копии в байтах на момент вызова.
Size() (int64, error)
// Close убирает копию. Зовётся на любом исходе, включая отказ.
Close() error
}
type FileRepository interface { type FileRepository interface {
Create(file *entity.File) error // Stage принимает содержимое потоком в рабочую копию с заданным
// расширением: по нему внешняя программа выбирает разбор. В память запись
// целиком не читается — расчётный потолок шесть часов.
Stage(ext string, content io.Reader) (WorkFile, error)
// StageEmpty заводит пустую рабочую копию с заданным расширением — под
// результат внешней программы, которая пишет по имени.
StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error)
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
// имени отправителя, не применяется.
CreateLocal(name string, work WorkFile) (*entity.File, error)
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
CreateRemote(objectKey string, size int64) (*entity.File, error)
GetByID(id string) (*entity.File, error) GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком.
Open(fileID string) (io.ReadCloser, error)
} }
type TranscriptJobRepository interface { type TranscriptJobRepository interface {
Create(job *entity.TranscribeJob) error Create(job *entity.TranscribeJob) error
Save(job *entity.TranscribeJob) error // Save сохраняет задачу, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все
// его шаги получают признак захвата от FindAndAcquire.
Save(job *entity.TranscribeJob, holder string) error
GetByID(id string) (*entity.TranscribeJob, error) GetByID(id string) (*entity.TranscribeJob, error)
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
// её попыток. Работы в состоянии нет — JobNotFoundError.
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
} }
+41 -51
View File
@@ -1,22 +1,30 @@
package http package http
import ( import (
"log" "log/slog"
"net/http" "net/http"
"time" "time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"github.com/gin-gonic/gin"
) )
type TranscribeHandler struct { type TranscribeHandler struct {
jobRepo contract.TranscriptJobRepository jobRepo contract.TranscriptJobRepository
trsService *service.TranscribeService trsService *service.TranscribeService
logger *slog.Logger
} }
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService) *TranscribeHandler { func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService} if logger == nil {
logger = slog.Default()
}
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
} }
type CreateTranscribeJobResponse struct { type CreateTranscribeJobResponse struct {
@@ -31,74 +39,56 @@ type GetTranscribeJobResponse struct {
TranscriptionText *string `json:"transcription_text,omitempty"` TranscriptionText *string `json:"transcription_text,omitempty"`
} }
func (h *TranscribeHandler) CreateTranscribeJob(c *gin.Context) { // Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
// сохранены — публичный контракт API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api")
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
// поэтому предел тела равен потолку самой записи.
api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize))
api.GET("/status/{id}", h.GetTranscribeJobStatus)
}
func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// Получаем файл из формы // Получаем файл из формы
file, header, err := c.Request.FormFile("audio") file, header, err := e.Request.FormFile("audio")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No audio file provided"}) return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
return
} }
defer file.Close() defer func() {
if err := file.Close(); err != nil {
h.logger.Error("Failed to close uploaded file", "error", err)
}
}()
job, err := h.trsService.CreateJobFromApi(file, header.Filename) job, err := h.trsService.CreateJobFromApi(file, header.Filename)
if err != nil { if err != nil {
log.Printf("Err: %v", err) // Второй раз отказ не логируем: приём назван конвенцией логирующей
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcibe job"}) // границей и уже написал о нём. Транспорт переводит ошибку в ответ.
return return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"})
} }
// Возвращаем успешный ответ // Возвращаем успешный ответ
response := CreateTranscribeJobResponse{ return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
JobID: job.Id, JobID: job.Id,
State: job.State, State: job.State,
} })
c.JSON(http.StatusCreated, response)
} }
func (h *TranscribeHandler) GetTranscribeJobStatus(c *gin.Context) { func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
jobID := c.Param("id") jobID := e.Request.PathValue("id")
job, err := h.jobRepo.GetByID(jobID) job, err := h.jobRepo.GetByID(jobID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
return
} }
c.JSON(http.StatusOK, GetTranscribeJobResponse{ return e.JSON(http.StatusOK, GetTranscribeJobResponse{
JobID: job.Id, JobID: job.Id,
State: job.State, State: job.State,
CreatedAt: job.CreatedAt, CreatedAt: job.CreatedAt,
TranscriptionText: job.TranscriptionText, TranscriptionText: job.TranscriptionText,
}) })
} }
func (h *TranscribeHandler) RunConversionJob(c *gin.Context) {
err := h.trsService.FindAndRunConversionJob()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *TranscribeHandler) RunTranscribeJob(c *gin.Context) {
err := h.trsService.FindAndRunTranscribeJob()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *TranscribeHandler) RunRecognitionCheckJob(c *gin.Context) {
err := h.trsService.FindAndRunTranscribeCheckJob()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
+532 -248
View File
@@ -2,252 +2,337 @@ package http
import ( import (
"bytes" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"io" "errors"
"fmt"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "regexp"
"path" "strings"
"path/filepath" "sync"
"runtime"
"testing" "testing"
"time"
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg" "github.com/pocketbase/pocketbase/apis"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" "github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "github.com/prometheus/client_golang/prometheus"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
) )
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) { // Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что
// Создаем временную базу данных в памяти // запись сохранена, задача заведена и ответ такой, какой обещан, — а не
db, err := sql.Open("sqlite3", ":memory:") // способность внешней программы разобрать звук. Внешних программ здесь нет
require.NoError(t, err) // ни одной, и видно это по списку импортов.
gq := goqu.New("sqlite3", db) // stubMetaViewer отдаёт заданную длительность либо заданную ошибку.
type stubMetaViewer struct {
err = goose.SetDialect("sqlite3") seconds int
require.NoError(t, err) err error
_, b, _, _ := runtime.Caller(0)
migpath, err := filepath.Abs(path.Join(b, "../../../../migrations"))
require.NoError(t, err)
err = goose.Up(db, migpath)
require.NoError(t, err)
return db, gq
} }
func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) { func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
gin.SetMode(gin.TestMode) if m.err != nil {
return nil, m.err
}
return &contract.AudioInfo{Seconds: m.seconds}, nil
}
db, gq := setupTestDB(t) // stubConverter молчалив: приём конвертацию не делает, и ни одна проверка
// этого файла её не зовёт.
type stubConverter struct{}
fileRepo := sqlite.NewFileRepository(db, gq) func (c *stubConverter) Convert(string, string) error { return nil }
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
metaviewer := ffmpegmv.NewFfmpegMetaViewer() // TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
converter := ffmpegconv.NewFfmpegConverter() type TestTgSender struct{}
recognizer := &recognizer.MemoryAudioRecognizer{}
// Создаем тестовый логгер func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ return nil
Level: slog.LevelError, // Только ошибки в тестах }
}))
// readableMetaViewer — источник метаданных, который читает любую запись.
func readableMetaViewer() *stubMetaViewer {
return &stubMetaViewer{seconds: 42}
}
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
// рабочий каталог процесса проверки не трогают.
type testEnv struct {
mux http.Handler
handler *TranscribeHandler
app core.App
journal *journalBuffer
}
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
// что пишущих в него потоков два: логгер сервиса и логгер обработчика.
type journalBuffer struct {
mu sync.Mutex
text strings.Builder
}
func (b *journalBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.Write(p)
}
// String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя
// поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы.
func (b *journalBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.String()
}
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
// ровно тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App {
t.Helper()
app, err := pbrepo.New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
app := newTestStorage(t)
pbrepo.BindPanelRules(app)
fileRepo := pbrepo.NewFileRepository(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app)
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
// отказа по-прежнему не попадают на экран и не размывают признак, по
// которому отличают новый красный шаг гейта от объявленного долга.
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
trsService := service.NewTranscribeService( trsService := service.NewTranscribeService(
jobRepo, jobRepo,
fileRepo, fileRepo,
metaviewer, metaviewer,
converter, &stubConverter{},
recognizer, &recognizer.MemoryAudioRecognizer{},
&TestTgSender{}, &TestTgSender{},
"data/files",
logger, logger,
) )
handler := NewTranscribeHandler(jobRepo, trsService) handler := NewTranscribeHandler(jobRepo, trsService, logger)
router := gin.New() // Роутер собирается тем же способом, что и боевой: маршруты вешает сам
router.MaxMultipartMemory = 32 << 20 // 32 MiB // обработчик, и проверка судит ту же цепочку, что и прод.
r, err := apis.NewRouter(app)
require.NoError(t, err)
handler.Register(r)
api := router.Group("/api") mux, err := r.BuildMux()
{ require.NoError(t, err)
api.POST("/audio", handler.CreateTranscribeJob)
api.GET("/status/:id", handler.GetTranscribeJobStatus)
}
return router, handler return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
} }
func createMultipartRequest(t *testing.T, audioFilePath string) (*http.Request, string) { // createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
// Открываем тестовый аудио файл // для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение.
file, err := os.Open(audioFilePath) func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request {
require.NoError(t, err) return createMultipartRequestWithField(t, "audio", fileName, content)
defer file.Close() }
// Создаем буфер для multipart формы // createMultipartRequestWithField кладёт запись в поле с заданным именем —
// нужно, чтобы построить форму без поля `audio`.
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request {
var buf bytes.Buffer var buf bytes.Buffer
writer := multipart.NewWriter(&buf) writer := multipart.NewWriter(&buf)
// Создаем поле для файла part, err := writer.CreateFormFile(field, fileName)
part, err := writer.CreateFormFile("audio", filepath.Base(audioFilePath))
require.NoError(t, err) require.NoError(t, err)
// Копируем содержимое файла _, err = part.Write(content)
_, err = io.Copy(part, file)
require.NoError(t, err) require.NoError(t, err)
// Закрываем writer
err = writer.Close() err = writer.Close()
require.NoError(t, err) require.NoError(t, err)
// Создаем HTTP запрос req := httptest.NewRequest("POST", "/api/audio", &buf)
req, err := http.NewRequest("POST", "/api/audio", &buf)
require.NoError(t, err)
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
return req, writer.FormDataContentType() return req
}
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err)
var names []string
for _, record := range records {
names = append(names, record.GetStringSlice("file")...)
}
return names
}
// countFiles считает записи о файлах.
func countFiles(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err)
return len(records)
}
// countJobs считает заведённые задачи расшифровки.
func countJobs(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
require.NoError(t, err)
return len(records)
}
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
// схемой, потому что без неё задача не пройдёт ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
t.Helper()
repo := pbrepo.NewFileRepository(env.app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
require.NoError(t, err)
job := &entity.TranscribeJob{State: entity.StateCreated, Source: entity.SourceApi, FileID: &file.Id}
require.NoError(t, env.handler.jobRepo.Create(job))
return job
}
// storedContent читает содержимое файла из хранилища.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
repo := pbrepo.NewFileRepository(env.app)
reader, err := repo.Open(fileID)
require.NoError(t, err)
defer reader.Close()
var buf bytes.Buffer
_, err = buf.ReadFrom(reader)
require.NoError(t, err)
return buf.Bytes()
} }
func TestCreateTranscribeJob_Success(t *testing.T) { func TestCreateTranscribeJob_Success(t *testing.T) {
// Создаем временную директорию для файлов env := setupTestEnv(t, readableMetaViewer())
tempDir := t.TempDir()
// Создаем структуру директорий для тестов content := []byte("содержимое записи, которое обязано доехать до диска целиком")
testDataDir := filepath.Join(tempDir, "data", "files") req := createMultipartRequest(t, "sample.m4a", content)
err := os.MkdirAll(testDataDir, 0755)
require.NoError(t, err)
// Временно меняем рабочую директорию для сохранения файлов
originalWd, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
require.NoError(t, err)
router, _ := setupTestRouter(t)
// Копируем тестовый файл во временную директорию
srcFile := filepath.Join(originalWd, "testdata", "sample.m4a")
dstFile := "sample.m4a"
src, err := os.Open(srcFile)
require.NoError(t, err)
defer src.Close()
dst, err := os.Create(dstFile)
require.NoError(t, err)
defer dst.Close()
_, err = io.Copy(dst, src)
require.NoError(t, err)
defer os.Remove(dstFile)
// Создаем запрос с тестовым аудио файлом
req, _ := createMultipartRequest(t, dstFile)
// Выполняем запрос
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
// Проверяем результат require.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, http.StatusCreated, w.Code)
// Имена полей ответа нормативны: контракт HTTP API объявлен необратимым.
// Судим по сырому JSON — разбор в CreateTranscribeJobResponse переименовал
// бы тег вместе с ожиданием, и проверка не смогла бы упасть.
var raw map[string]json.RawMessage
err := json.Unmarshal(w.Body.Bytes(), &raw)
require.NoError(t, err)
assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status")
var response CreateTranscribeJobResponse var response CreateTranscribeJobResponse
err = json.Unmarshal(w.Body.Bytes(), &response) err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err) require.NoError(t, err)
// Проверяем, что возвращается корректный ответ
assert.NotEmpty(t, response.JobID) assert.NotEmpty(t, response.JobID)
assert.Equal(t, entity.StateCreated, response.State) assert.Equal(t, entity.StateCreated, response.State)
// Проверяем, что файл был сохранен // Задача действительно заведена, а не только названа в ответе: иначе
files, err := filepath.Glob(filepath.Join("data", "files", "*")) // отправитель получит идентификатор записи, которой не будет никогда.
require.NoError(t, err) require.Equal(t, 1, countJobs(t, env))
assert.Len(t, files, 1)
// Проверяем размер сохраненного файла job, err := env.handler.jobRepo.GetByID(response.JobID)
fileInfo, err := os.Stat(files[0])
require.NoError(t, err) require.NoError(t, err)
assert.Greater(t, fileInfo.Size(), int64(0)) assert.Equal(t, entity.StateCreated, job.State)
require.NotNil(t, job.FileID)
assert.NotEmpty(t, *job.FileID)
// Содержимое лежит в хранилище одним файлом и целиком.
require.Equal(t, 1, countFiles(t, env))
assert.Equal(t, content, storedContent(t, env, *job.FileID))
} }
func TestCreateTranscribeJob_NoFile(t *testing.T) { func TestCreateTranscribeJob_NoFile(t *testing.T) {
router, _ := setupTestRouter(t) // Две ветки одного сценария: тела нет вовсе и форма есть, а поля в ней нет.
// Вторая — та, что описана требованием; первая ходит тем же путём.
testCases := []struct {
name string
req func(t *testing.T) *http.Request
}{
{
name: "no body at all",
req: func(t *testing.T) *http.Request {
// Запрос строится так, как его видит сервер: у пришедшего по
// проводу тело не бывает пустым указателем.
return httptest.NewRequest("POST", "/api/audio", http.NoBody)
},
},
{
name: "form without audio field",
req: func(t *testing.T) *http.Request {
return createMultipartRequestWithField(t, "attachment", "sample.m4a", []byte("запись"))
},
},
}
// Создаем запрос без файла for _, tc := range testCases {
req, err := http.NewRequest("POST", "/api/audio", nil) t.Run(tc.name, func(t *testing.T) {
require.NoError(t, err) env := setupTestEnv(t, readableMetaViewer())
// Выполняем запрос w := httptest.NewRecorder()
w := httptest.NewRecorder() env.mux.ServeHTTP(w, tc.req(t))
router.ServeHTTP(w, req)
// Проверяем результат require.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]string var response map[string]string
err = json.Unmarshal(w.Body.Bytes(), &response) err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "No audio file provided", response["error"]) assert.Equal(t, "No audio file provided", response["error"])
assert.Equal(t, 0, countFiles(t, env))
assert.Equal(t, 0, countJobs(t, env))
})
}
} }
func TestCreateTranscribeJob_EmptyFile(t *testing.T) { func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
// Создаем временную директорию для файлов env := setupTestEnv(t, readableMetaViewer())
tempDir := t.TempDir()
// Создаем структуру директорий для тестов // Собственного порога по размеру у приёма нет: годность записи судит
testDataDir := filepath.Join(tempDir, "data", "files") // источник метаданных, а не приём.
err := os.MkdirAll(testDataDir, 0755) req := createMultipartRequest(t, "empty.m4a", nil)
require.NoError(t, err)
// Временно меняем рабочую директорию для сохранения файлов
originalWd, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
require.NoError(t, err)
router, _ := setupTestRouter(t)
// Создаем пустой временный файл в текущей директории теста
emptyFile := "empty.m4a"
f, err := os.Create(emptyFile)
require.NoError(t, err)
f.Close()
defer os.Remove(emptyFile)
// Создаем запрос с пустым файлом
req, _ := createMultipartRequest(t, emptyFile)
// Выполняем запрос
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
// Проверяем результат - даже пустой файл должен быть принят require.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse var response CreateTranscribeJobResponse
err = json.Unmarshal(w.Body.Bytes(), &response) err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, response.JobID) assert.NotEmpty(t, response.JobID)
@@ -257,135 +342,334 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) { func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
filename string fileName string
expectExt string expectExt string
}{ }{
{ {
name: "m4a file", name: "m4a file",
filename: "test.m4a", fileName: "test.m4a",
expectExt: ".m4a", expectExt: ".m4a",
}, },
{ {
name: "mp3 file", name: "mp3 file",
filename: "test.mp3", fileName: "test.mp3",
expectExt: ".mp3", expectExt: ".mp3",
}, },
{ {
name: "wav file", name: "wav file",
filename: "test.wav", fileName: "test.wav",
expectExt: ".wav", expectExt: ".wav",
}, },
{ {
name: "file without extension", name: "file without extension",
filename: "test", fileName: "test",
expectExt: ".audio", expectExt: ".audio",
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Создаем временную директорию для файлов env := setupTestEnv(t, readableMetaViewer())
tempDir := t.TempDir()
// Создаем структуру директорий для тестов req := createMultipartRequest(t, tc.fileName, []byte("запись"))
testDataDir := filepath.Join(tempDir, "data", "files")
err := os.MkdirAll(testDataDir, 0755)
require.NoError(t, err)
// Временно меняем рабочую директорию для сохранения файлов
originalWd, err := os.Getwd()
require.NoError(t, err)
defer os.Chdir(originalWd)
err = os.Chdir(tempDir)
require.NoError(t, err)
router, _ := setupTestRouter(t)
// Создаем временный файл с нужным именем в текущей директории теста
testFile := tc.filename
f, err := os.Create(testFile)
require.NoError(t, err)
f.WriteString("test audio content")
f.Close()
defer os.Remove(testFile)
// Создаем запрос
req, _ := createMultipartRequest(t, testFile)
// Выполняем запрос
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
// Проверяем результат require.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, http.StatusCreated, w.Code)
// Проверяем, что файл сохранен с правильным расширением names := storedFileNames(t, env)
files, err := filepath.Glob(filepath.Join("data", "files", "*"+tc.expectExt)) require.Len(t, names, 1)
require.NoError(t, err)
assert.Len(t, files, 1) // Имя отправителя в хранилище не попадает: имя файла — свой
// идентификатор, от отправителя взято только расширение. Суффикс
// дописывает само хранилище, поэтому сверяем хвост, а не Ext.
assert.True(t, strings.HasSuffix(names[0], tc.expectExt),
"имя в хранилище %q оканчивается на %q", names[0], tc.expectExt)
assert.NotContains(t, names[0], strings.TrimSuffix(tc.fileName, tc.expectExt))
}) })
} }
} }
func TestGetTranscribeJobStatus_Success(t *testing.T) { // Имя, данное отправителем, в хранилище не попадает целиком — умолчание
router, handler := setupTestRouter(t) // библиотеки, строящее имя файла из него, не применяется. Проверка отдельная от
// перебора расширений: там сверяется хвост, здесь — что основы имени нет.
func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Создаем тестовую запись в базе данных req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
job := &entity.TranscribeJob{
Id: "test-job-id",
State: entity.StateCreated,
FileID: nil,
IsError: false,
CreatedAt: time.Now(),
}
err := handler.jobRepo.Create(job)
require.NoError(t, err)
// Создаем запрос
req, err := http.NewRequest("GET", "/api/status/test-job-id", nil)
require.NoError(t, err)
// Выполняем запрос
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
// Проверяем результат require.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, http.StatusOK, w.Code)
names := storedFileNames(t, env)
require.Len(t, names, 1)
assert.NotContains(t, names[0], "секретное-слово",
"имя, данное отправителем, в хранилище не попадает")
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
"расширение при этом сохраняется: %q", names[0])
}
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
var response map[string]string
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Причина отказа принадлежит журналу, а не отправителю.
assert.Equal(t, "Failed to create transcibe job", response["error"])
assert.NotContains(t, w.Body.String(), "не удалось прочитать запись")
assert.Equal(t, 0, countJobs(t, env))
}
// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
// пропустил бы.
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// Тексты, по которым проверки находят журнальные строки. Первый — записанный
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
// Когда долг закроют, правка будет здесь и одна.
const (
msgIntake = "Creating transcribe job"
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
// логировать ошибку один раз, и повторная запись транспорта снята.
msgIntakeErr = "Failed to get file info"
)
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Метка стоит в основе имени, а расширение обычное: расширение запретом не
// накрыто и в журнале остаётся законно.
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
journal := env.journal.String()
// Сперва — что журнал приёма вообще перехвачен. Без этого утверждения
// пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав.
require.Contains(t, journal, msgIntake,
"строка приёма попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker,
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
}
func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
// Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём
// назван конвенцией логирующей границей, и цепочка `%w` осядет полем error.
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
journal := env.journal.String()
// Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою
// строку, и без неё оракул молча сузился бы вдвое.
require.Contains(t, journal, msgIntakeErr,
"строка приёма об отказе попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker,
"имя отправителя не пишется в журнал и на пути отказа")
}
// Имя, под которым файл лёг в хранилище, — это последняя часть ссылки
// `/api/files/...`, по которой запись скачивают. Попав в журнал, строка стала бы
// бессрочным ключом к чужому аудио, поэтому в журнал идёт имя, заданное
// сервисом, а суффикс хранилища — нет.
func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
names := storedFileNames(t, env)
require.Len(t, names, 1)
journal := env.journal.String()
require.Contains(t, journal, msgIntake, "журнал приёма перехвачен")
assert.NotContains(t, journal, names[0],
"имени файла в хранилище в журнале нет: по нему собирается ссылка на скачивание")
}
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
content := []byte("содержимое записи")
req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID)
require.NoError(t, err)
require.NotNil(t, job.FileID)
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
// сохранённой при пустом журнале.
journal := env.journal.String()
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
// конвенции — JSON. Утверждение держится на значении и переживёт замену.
assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal,
"размер принятой записи в байтах в журнале остаётся")
// Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не
// будет вовсе, и разбор постфактум опереться будет не на что. Уровень
// ищется в строке самого приёма — соседние строки тоже идут на INFO, и
// поиск по всему журналу не упал бы от понижения этой.
assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal,
"строка приёма остаётся на уровне INFO")
// И она ровно одна: вторая строка приёма означала бы второй путь заведения
// задачи мимо общей точки, то есть место, куда запрет не доехал.
assert.Equal(t, 1, strings.Count(journal, msgIntake),
"на принятую запись приходится одна журнальная строка приёма")
}
// metricLabelValues собирает значения меток названного семейства метрик из
// общего реестра процесса. Проверка судит реестр, а не функцию приведения:
// приведение, снятое в точке употребления, функцию не ломает, а хвост имени
// уходит на страницу метрик, которая отдаётся без проверки отправителя.
func metricLabelValues(t *testing.T, family string) []string {
families, err := prometheus.DefaultGatherer.Gather()
require.NoError(t, err)
var values []string
for _, mf := range families {
if mf.GetName() != family {
continue
}
for _, m := range mf.GetMetric() {
for _, label := range m.GetLabel() {
values = append(values, label.GetValue())
}
}
}
return values
}
func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Хвост после последней точки — это тоже кусок имени, данного отправителем.
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
values := metricLabelValues(t, "transcriber_input_file_size_bytes")
require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом")
assert.NotContains(t, values, "."+senderNameMarker,
"метка метрики не несёт хвоста имени, данного отправителем")
assert.NotContains(t, values, senderNameMarker,
"метка метрики не несёт хвоста имени и без ведущей точки")
assert.Contains(t, values, "other",
"незнакомое расширение приведено к общему значению")
// А в хранилище расширение остаётся пришедшим: приведение сюда не
// распространяется.
names := storedFileNames(t, env)
require.Len(t, names, 1)
assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker),
"имя в хранилище сохраняет пришедшее расширение: %q", names[0])
}
func TestGetTranscribeJobStatus_Success(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse var response GetTranscribeJobResponse
err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
require.NoError(t, err)
assert.Equal(t, "test-job-id", response.JobID) assert.Equal(t, job.Id, response.JobID)
assert.Equal(t, entity.StateCreated, response.State) assert.Equal(t, entity.StateCreated, response.State)
assert.NotZero(t, response.CreatedAt) assert.NotZero(t, response.CreatedAt)
} }
func TestGetTranscribeJobStatus_NotFound(t *testing.T) { func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
router, _ := setupTestRouter(t) env := setupTestEnv(t, readableMetaViewer())
// Создаем запрос с несуществующим ID job := jobWithFile(t, env)
req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil)
require.NoError(t, err) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
// Выполняем запрос
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
// Проверяем результат require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, http.StatusNotFound, w.Code)
// Судим по сырому JSON: пустая строка на месте отсутствующего текста
// читается клиентом как «расшифровка пуста», и разобранная структура
// эти два случая не различает.
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status")
assert.Contains(t, raw, "created_at")
assert.NotContains(t, raw, "transcription_text")
}
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
var response map[string]string var response map[string]string
err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
require.NoError(t, err)
assert.Equal(t, "Job not found", response["error"]) assert.Equal(t, "Job not found", response["error"])
} }
type TestTgSender struct{}
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
return nil
}
+6 -1
View File
@@ -2,6 +2,7 @@ package worker
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"strconv" "strconv"
"time" "time"
@@ -48,7 +49,11 @@ func (w *CallbackWorker) Start(ctx context.Context) {
return return
default: default:
err := w.f() err := w.f()
_, isNoop := err.(*contract.NoopJobError) // Признак узнаётся по смыслу, а не по точной форме значения:
// приведение типа видело только вершину цепочки и сломалось бы от
// первой же обёртки `%w`, которая в проекте — умолчание.
var noop *contract.NoopJobError
isNoop := errors.As(err, &noop)
if !isNoop { if !isNoop {
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc() metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
} }
+204
View File
@@ -0,0 +1,204 @@
package worker
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"github.com/prometheus/client_golang/prometheus"
)
// Проверки этого файла судят одну развилку воркера: пустой прогон против
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
// цена срабатывания отложенная: три воркера опрашивают базу раз в секунду, и
// пустой прогон, принятый за отказ, даёт три записи в секунду и столько же
// засчитанных сбоев, которых не было.
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
// читает проверка — отсюда мьютекс.
type journalBuffer struct {
mu sync.Mutex
text strings.Builder
}
func (b *journalBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.Write(p)
}
func (b *journalBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.String()
}
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
// Цикл воркера бесконечен и спит секунду между прогонами, поэтому контекст
// отменяется сразу после первого вызова работы: ждать второго прогона нечего, а
// секунда сна на проверку — цена ни за что.
func runOnce(t *testing.T, name string, work func() error) string {
t.Helper()
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var once sync.Once
done := make(chan struct{})
w := NewCallbackWorker(name, func() error {
err := work()
once.Do(func() {
cancel()
close(done)
})
return err
}, logger)
finished := make(chan struct{})
go func() {
w.Start(ctx)
close(finished)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("работа воркера не была вызвана")
}
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("воркер не остановился по отмене контекста")
}
return journal.String()
}
// runRecords оставляет от журнала только записи об исходе прогона. Жизненный
// цикл самого воркера — старт и остановка — по конвенции идёт на INFO и к
// прогону не относится; требование говорит о том, что воркер пишет про свой
// прогон, а не о том, что он молчит вообще.
func runRecords(journal string) string {
var kept []string
for _, line := range strings.Split(strings.TrimSpace(journal), "\n") {
if line == "" {
continue
}
if strings.Contains(line, "msg=\"Worker started\"") ||
strings.Contains(line, "msg=\"Worker received shutdown signal") {
continue
}
kept = append(kept, line)
}
return strings.Join(kept, "\n")
}
// jobCount читает счётчик работы воркера из общего реестра процесса. Судит
// реестр, а не переменную пакета: метка, потерянная в точке употребления,
// переменную не ломает, а на странице метрик видна.
func jobCount(t *testing.T, worker, errLabel string) float64 {
t.Helper()
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("не удалось собрать метрики: %v", err)
}
for _, mf := range families {
if mf.GetName() != "transcriber_worker_job_count" {
continue
}
for _, m := range mf.GetMetric() {
var gotWorker, gotErr string
for _, label := range m.GetLabel() {
switch label.GetName() {
case "name":
gotWorker = label.GetValue()
case "error":
gotErr = label.GetValue()
}
}
if gotWorker == worker && gotErr == errLabel {
return m.GetCounter().GetValue()
}
}
}
return 0
}
// Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть
// проверка прошла бы и на починенном, и на сломанном коде.
func TestWrappedNoopIsNotAFailure(t *testing.T) {
const name = "wrapped_noop_worker"
before := jobCount(t, name, "false")
beforeErr := jobCount(t, name, "true")
journal := runOnce(t, name, func() error {
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
})
// Записи о старте и остановке воркера законны и к прогону не относятся —
// проверяется отсутствие записи об исходе прогона.
if got := runRecords(journal); got != "" {
t.Errorf("пустой прогон попал в журнал: %q", got)
}
if got := jobCount(t, name, "false"); got != before {
t.Errorf("счётчик успешных прогонов вырос на пустом прогоне: было %v, стало %v", before, got)
}
if got := jobCount(t, name, "true"); got != beforeErr {
t.Errorf("пустой прогон засчитан отказом: было %v, стало %v", beforeErr, got)
}
}
// Без этой проверки оракул был бы зелен и на коде, который не считает отказом
// вообще ничего.
func TestFailureIsLoggedAndCounted(t *testing.T) {
const name = "failing_worker"
before := jobCount(t, name, "true")
journal := runOnce(t, name, func() error {
return errors.New("database is gone")
})
if !strings.Contains(journal, "database is gone") {
t.Errorf("отказ не виден владельцу: журнал %q", journal)
}
if got := jobCount(t, name, "true"); got != before+1 {
t.Errorf("отказ не засчитан: было %v, стало %v", before, got)
}
}
// Счёт успешных прогонов — знаменатель доли отказов. Реализация, снявшая его,
// проходит обе проверки выше, а владелец теряет способность отличить «три
// прогона в секунду, все отказали» от «три отказа среди тысячи прогонов».
func TestSuccessIsCounted(t *testing.T) {
const name = "successful_worker"
before := jobCount(t, name, "false")
journal := runOnce(t, name, func() error {
return nil
})
if got := jobCount(t, name, "false"); got != before+1 {
t.Errorf("успешный прогон не засчитан: было %v, стало %v", before, got)
}
if strings.Contains(journal, "Worker error") {
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
}
}
+22 -14
View File
@@ -4,25 +4,33 @@ import (
"time" "time"
) )
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
// разным вещам запись о файле и хранилище, в котором она лежит.
const ( const (
StorageLocal = "local" LocationLocal = "local"
StorageS3 = "s3" LocationS3 = "s3"
) )
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
// потолка записи в шесть часов с запасом на видео, а не из замера.
//
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она
// дойдёт до обработчика — без строки в журнале приёма.
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
// File — одна физическая копия: исходник, результат конвертации и копия во
// внешнем хранилище — три разные записи.
type File struct { type File struct {
Id string Id string
Storage string Location string
// FileName — имя, под которым файл лежит: у местной копии это имя, заданное
// сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному
// имени не дописывает: суффикс появляется только у имён, которые оно строит
// само из имени отправителя, а это умолчание не применяется.
FileName string FileName string
Size int64 Size int64
CreatedAt time.Time CreatedAt time.Time
} }
func (f *File) CopyWithStorage(newId, storage string) *File {
return &File{
Id: newId,
Storage: storage,
FileName: f.FileName,
Size: f.Size,
CreatedAt: time.Now(),
}
}
+30 -2
View File
@@ -9,11 +9,11 @@ type TranscribeJob struct {
State string State string
Source string Source string
FileID *string FileID *string
IsError bool
ErrorText *string ErrorText *string
AcquisitionID *string AcquisitionID *string
AcquireTime *time.Time AcquireTime *time.Time
DelayTime *time.Time DelayTime *time.Time
Attempts int // Число попыток: растёт при захвате, обнуляется на шаге без отказа
RecognitionOpID *string // ID операции распознавания в Yandex Cloud RecognitionOpID *string // ID операции распознавания в Yandex Cloud
TranscriptionText *string // Результат распознавания TranscriptionText *string // Результат распознавания
TgChatId *int64 // Telegram: в какой чат отправить результат распознавания TgChatId *int64 // Telegram: в какой чат отправить результат распознавания
@@ -28,6 +28,11 @@ const (
StateTranscribe = "transcribe" StateTranscribe = "transcribe"
StateDone = "done" StateDone = "done"
StateFailed = "failed" StateFailed = "failed"
// StateDead — задача, которую мы повторяли и перестали. От `failed` она
// отличается тем, чей это приговор: в `failed` задачу переводит шаг,
// рассудивший об этой записи окончательно, а сюда она уходит без такого
// суждения. Ни один шаг конвейера в неё не переводит сам.
StateDead = "dead"
) )
const ( const (
@@ -43,6 +48,10 @@ func (j *TranscribeJob) MoveToState(state string) {
j.DelayTime = nil j.DelayTime = nil
j.AcquisitionID = nil j.AcquisitionID = nil
j.AcquireTime = nil j.AcquireTime = nil
// Шаг, дошедший до перехода, завершился без отказа, а попытки считают
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
// их поштучно и умерла бы здоровой.
j.Attempts = 0
j.UpdatedAt = time.Now() j.UpdatedAt = time.Now()
} }
@@ -59,6 +68,25 @@ func (j *TranscribeJob) Done(transcriptionText string) {
func (j *TranscribeJob) Fail(errText string) { func (j *TranscribeJob) Fail(errText string) {
j.MoveToState(StateFailed) j.MoveToState(StateFailed)
j.IsError = true j.ErrorText = &errText
}
// RetryAfter освобождает отказавшую задачу для повтора: захват снимается,
// пауза ставится, а число попыток сохраняется — по нему растёт пауза и
// наступает предел.
func (j *TranscribeJob) RetryAfter(delay time.Time) {
j.AcquisitionID = nil
j.AcquireTime = nil
j.DelayTime = &delay
j.UpdatedAt = time.Now()
}
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
// попыток при этом сохраняется: по нему видно, сколько раз мы пробовали, а
// возвращает задачу в работу владелец правкой состояния.
func (j *TranscribeJob) Die(errText string) {
attempts := j.Attempts
j.MoveToState(StateDead)
j.Attempts = attempts
j.ErrorText = &errText j.ErrorText = &errText
} }
+69
View File
@@ -0,0 +1,69 @@
package metrics
import (
"strconv"
"strings"
)
// OtherFormatLabel — значение метки для всего, чего нет в перечне known-форматов.
const OtherFormatLabel = "other"
// knownFormats — закрытый перечень расширений, которые допускаются меткой.
//
// Состав: пути, которые выдаёт Telegram (голосовое приходит как
// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по
// HTTP, плюс собственное умолчание сервиса на случай имени без расширения.
// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень
// намеренно не равен: тот судит по типу содержимого и своим списком пользуется
// лишь когда типа нет, а сюда попадает и то, что приходит другими путями.
// Сведение двух списков в один уронило бы основной вход сервиса в `other`.
var knownFormats = map[string]struct{}{
"mp3": {},
"wav": {},
"ogg": {},
"oga": {},
"opus": {},
"flac": {},
"m4a": {},
"aac": {},
"wma": {},
"mp4": {},
"mkv": {},
"mov": {},
"avi": {},
"webm": {},
"audio": {}, // умолчание сервиса, когда расширения в имени не было
}
// FormatLabel приводит расширение к виду, годному для метки метрики.
//
// Расширение приходит из имени, которое дал отправитель, и потому может быть
// чем угодно: имя `запись.тайное-слово` отдаёт `тайное-слово`. Страница метрик
// открыта, то есть метка — поверхность пошире журнала. Незнакомое значение
// заменяется одним общим: это закрывает и утечку куска имени, и рост числа
// временных рядов, которым иначе распоряжается анонимный отправитель.
//
// Имени файла на диске это не касается — там расширение остаётся пришедшим.
// ObserveInputFileSize записывает размер принятой записи. Расширение приводится
// здесь, а не у вызывающего: сырая точка употребления — это место, где хвост
// имени отправителя однажды снова уедет наружу, и проверка у вызывающего этого
// не заметит.
func ObserveInputFileSize(ext string, size int64) {
InputFileSizeHistogram.WithLabelValues(FormatLabel(ext)).Observe(float64(size))
}
// ObserveConversionDuration записывает длительность конвертации. Исходный формат
// приводится по той же причине, что и в приёме.
func ObserveConversionDuration(srcExt, targetFormat string, failed bool, seconds float64) {
ConversionDurationHistogram.
WithLabelValues(FormatLabel(srcExt), targetFormat, strconv.FormatBool(failed)).
Observe(seconds)
}
func FormatLabel(ext string) string {
normalized := strings.ToLower(strings.TrimPrefix(ext, "."))
if _, ok := knownFormats[normalized]; ok {
return normalized
}
return OtherFormatLabel
}
+117
View File
@@ -0,0 +1,117 @@
package metrics
import (
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
)
// Метка метрики уезжает на страницу, которая отдаётся без проверки отправителя,
// поэтому судим здесь ровно одно: что наружу выходит только известное значение.
func TestFormatLabel(t *testing.T) {
testCases := []struct {
name string
ext string
want string
}{
{name: "известное расширение с точкой", ext: ".mp3", want: "mp3"},
{name: "известное расширение без точки", ext: "mp3", want: "mp3"},
{name: "регистр приводится", ext: ".MP3", want: "mp3"},
{name: "умолчание сервиса", ext: ".audio", want: "audio"},
// Голосовое из Telegram приходит путём вида `voice/file_N.oga`, кружок —
// с `.mp4`. Это основной вход сервиса: усечение перечня до списка, по
// которому бот отбирает документы, схлопнуло бы его в общее значение.
{name: "голосовое из Telegram", ext: ".oga", want: "oga"},
{name: "видеокружок из Telegram", ext: ".mp4", want: "mp4"},
{name: "opus", ext: ".opus", want: "opus"},
// Значение сверяется с литералом, а не с самой константой: сверка с
// константой утверждала бы тавтологию, а спека нормирует слово `other`
// дословно.
{name: "хвост имени отправителя", ext: ".тайное-слово", want: "other"},
{name: "часть даты в имени", ext: ".08", want: "other"},
{name: "пустое", ext: "", want: "other"},
{name: "одна точка", ext: ".", want: "other"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := FormatLabel(tc.ext); got != tc.want {
t.Errorf("FormatLabel(%q) = %q, ожидалось %q", tc.ext, got, tc.want)
}
})
}
}
// labelValues собирает значения меток названного семейства из общего реестра.
func labelValues(t *testing.T, family string) []string {
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("не удалось прочитать реестр метрик: %v", err)
}
var values []string
for _, mf := range families {
if mf.GetName() != family {
continue
}
for _, m := range mf.GetMetric() {
for _, label := range m.GetLabel() {
values = append(values, label.GetValue())
}
}
}
return values
}
// Метку конвертации приёмом по HTTP не достать: шаг живёт в воркере, и своего
// окружения у него нет. Поэтому обёртка судится здесь — по реестру, а не по
// чистой функции: сырое употребление гистограммы мимо обёртки и есть то место,
// где хвост имени отправителя однажды снова уедет наружу.
func TestObserveConversionDurationLabelsAreKnown(t *testing.T) {
const tail = "CONVLEAKMARKER5X8"
ObserveConversionDuration("."+tail, "ogg", false, 1)
values := labelValues(t, "transcriber_conversion_duration_seconds")
if len(values) == 0 {
t.Fatal("метрика длительности конвертации не заполнилась")
}
assertTailAbsentAndOtherPresent(t, values, tail)
}
// Та же проверка для метки приёма — на случай, если обёртку обойдут только с
// одной стороны.
func TestObserveInputFileSizeLabelsAreKnown(t *testing.T) {
const tail = "INPUTLEAKMARKER5X8"
ObserveInputFileSize("."+tail, 42)
values := labelValues(t, "transcriber_input_file_size_bytes")
if len(values) == 0 {
t.Fatal("метрика размера принятой записи не заполнилась")
}
assertTailAbsentAndOtherPresent(t, values, tail)
}
// assertTailAbsentAndOtherPresent судит значения метки по двум признакам сразу.
// Одного «хвоста нет» мало: приведение, ослабленное до смены регистра, хвост
// пропустило бы, а поиск заглавного маркера в строчном значении его не нашёл бы.
// Поэтому сравнение регистронезависимое, и рядом стоит второй признак — что
// незнакомое расширение вообще доехало до общего значения.
func assertTailAbsentAndOtherPresent(t *testing.T, values []string, tail string) {
t.Helper()
for _, v := range values {
if strings.Contains(strings.ToLower(v), strings.ToLower(tail)) {
t.Fatalf("метка несёт хвост имени, данного отправителем: %q", v)
}
}
for _, v := range values {
if v == "other" {
return
}
}
t.Fatal("незнакомое расширение не приведено к общему значению")
}
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"errors"
"fmt"
"io"
"log/slog"
"testing"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает
// «подходящей задачи не нашлось», сервис переводит это в «работы нет», и уже
// его читает воркер. Проверки воркера подменяют работу целиком и второе звено
// не видят — без этого файла правку в сервисе принимал бы только линтер, а он
// судит форму записи, а не то, узнаётся ли признак на самом деле.
// stubJobRepo отдаёт заданную ошибку на запрос задачи. Прочих методов запроса
// задачи проверки этого файла не зовут.
type stubJobRepo struct {
err error
}
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
func (r *stubJobRepo) GetByID(string) (*entity.TranscribeJob, error) {
return nil, errors.New("не зовётся этими проверками")
}
func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.TranscribeJob, error) {
return nil, r.err
}
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
}
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
// метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением
// типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал
// бы писать в журнал раз в секунду на каждом из трёх воркеров.
func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{
err: fmt.Errorf("find and acquire job: %w",
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}),
})
_, _, err := svc.findJob("created", time.Minute)
var noop *contract.NoopJobError
if !errors.As(err, &noop) {
t.Fatalf("обёрнутое «задачи нет» не переведено в пустой прогон: получено %v", err)
}
if noop.State != "created" {
t.Errorf("состояние потеряно при переводе: %q", noop.State)
}
}
// Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не
// должен, иначе задача молча крутилась бы в цикле без единой записи.
func TestFindJobKeepsRealFailure(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")})
_, _, err := svc.findJob("created", time.Minute)
var noop *contract.NoopJobError
if errors.As(err, &noop) {
t.Fatalf("отказ хранилища зачтён пустым прогоном: %v", err)
}
if err == nil {
t.Fatal("отказ хранилища потерян")
}
}
+19
View File
@@ -0,0 +1,19 @@
package service
import (
"testing"
"git.vakhrushev.me/av/transcriber/internal/metrics"
)
// Умолчание расширения живёт в этом пакете, а перечень значений метки — в
// пакете метрик, и связывает их только совпадение двух литералов. Компилятор
// расхождения не поймает: смена умолчания просто сложит все записи без
// расширения в общее значение, и метка перестанет отличать «расширения не было»
// от чужого хвоста в имени.
func TestDefaultAudioExtIsKnownToMetrics(t *testing.T) {
if got := metrics.FormatLabel(defaultAudioExt); got == metrics.OtherFormatLabel {
t.Fatalf("умолчание %q не входит в перечень известных форматов: метка отдаёт %q",
defaultAudioExt, got)
}
}
+362
View File
@@ -0,0 +1,362 @@
package service
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Проверки конвейера идут против настоящего хранилища: захват, число попыток и
// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы
// собственную заглушку, а не то, что делает база.
// failingConverter отказывает на каждой попытке.
type failingConverter struct{}
func (c *failingConverter) Convert(string, string) error {
return errors.New("конвертация не удалась")
}
type okMetaViewer struct{}
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
return &contract.AudioInfo{Seconds: 1}, nil
}
type failingMetaViewer struct{}
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
return nil, errors.New("запись не читается")
}
// recordingSender запоминает, что и куда отправлено.
type recordingSender struct {
messages []string
}
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
s.messages = append(s.messages, text)
return nil
}
type pipelineEnv struct {
app core.App
service *TranscribeService
jobRepo *pbrepo.TranscriptJobRepository
fileRepo *pbrepo.FileRepository
sender *recordingSender
}
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
t.Helper()
app, err := pbrepo.New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
// Правила панели вешаются и здесь: конфигурация под проверкой обязана
// совпадать с боевой, иначе утверждения говорят про прод то, чего в проде
// нет.
pbrepo.BindPanelRules(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app)
fileRepo := pbrepo.NewFileRepository(app)
sender := &recordingSender{}
svc := NewTranscribeService(
jobRepo,
fileRepo,
metaviewer,
converter,
&recognizer.MemoryAudioRecognizer{},
sender,
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
}
// newTelegramJob заводит задачу с записью — так, как её завёл бы приём.
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
chatId := int64(100)
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
require.NoError(t, err)
return job
}
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка
// судит счётчик попыток, а не то, умеет ли она ждать.
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
require.NoError(t, err)
record.Set("delay_time", "")
require.NoError(t, env.app.Save(record))
}
// rotAcquisition отодвигает время захвата так, чтобы он протух: так это
// выглядит, когда шаг оборвался вместе с процессом.
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
require.NoError(t, env.app.Save(record))
}
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает,
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть
// не подходит ни под один.
func TestJobDiesAfterAttemptLimit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем
// его отказом источника метаданных внутри самого шага конвертации нельзя, и
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит
// при гибели процесса.
for i := 0; i < maxAttempts; i++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
}
rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу.
err := env.service.FindAndRunConversionJob()
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче")
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
// И из выборки она исчезла.
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
}
// Мёртвая задача возвращается в работу правкой состояния.
func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
for i := 0; i < maxAttempts; i++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
}
require.Error(t, env.service.FindAndRunConversionJob())
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("state", entity.StateCreated)
require.NoError(t, env.app.Save(record))
again, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
require.NoError(t, err, "снятое состояние возвращает задачу в работу")
assert.Equal(t, job.Id, again.Id)
}
// Отказ шага не оставляет задачу захваченной до конца срока: захват снимается,
// и задача ждёт нарастающую паузу. Иначе повтор наступал бы через восемь часов.
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
// Источник метаданных отказывает — это отказ шага, а не приговор записи.
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
// рабочей копии — то есть отказом, а не приговором записи.
empty, err := env.fileRepo.CreateRemote("object-key", 1)
require.NoError(t, err)
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("file", empty.Id)
require.NoError(t, env.app.Save(record))
// Первый отказ.
require.Error(t, env.service.FindAndRunConversionJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
require.NotNil(t, after.DelayTime, "пауза поставлена")
firstDelay := time.Until(*after.DelayTime)
// Второй отказ — с той же задачи, пауза снята вручную.
clearDelay(t, env, job.Id)
require.Error(t, env.service.FindAndRunConversionJob())
after, err = env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime)
secondDelay := time.Until(*after.DelayTime)
assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой")
}
// Пауза растёт с числом попыток и упирается в потолок.
func TestRetryDelayGrowsAndCaps(t *testing.T) {
assert.Equal(t, retryDelayBase, retryDelay(1))
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
assert.Greater(t, retryDelay(3), retryDelay(2))
assert.Equal(t, retryDelayCap, retryDelay(100), "пауза упирается в потолок")
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
}
// Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это
// шестичасовая запись во временном каталоге, и узнать о ней неоткуда.
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "рабочей копии после отказа не остаётся")
}
// Успешный приём тоже за собой убирает: копия нужна была только на время
// укладки в хранилище.
func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
}
// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется
// только после того, как запись о новом файле существует.
func TestJobNeverPointsToMissingFile(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
// исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.NotNil(t, after.FileID)
file, err := env.fileRepo.GetByID(*after.FileID)
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
assert.NotEmpty(t, file.FileName)
}
// Содержимое доезжает до хранилища целиком и читается обратно тем же.
func TestStoredContentSurvivesRoundTrip(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
content := strings.Repeat("запись ", 1000)
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
reader, err := env.fileRepo.Open(*job.FileID)
require.NoError(t, err)
defer reader.Close()
stored, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, content, string(stored))
// И длина в учёте совпадает с длиной принятого.
file, err := env.fileRepo.GetByID(*job.FileID)
require.NoError(t, err)
assert.Equal(t, int64(len(content)), file.Size)
}
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают
// шаги, отдающие файл внешней программе.
func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
work, err := env.fileRepo.Localize(*job.FileID)
require.NoError(t, err)
content, err := os.ReadFile(work.Path())
require.NoError(t, err)
assert.Equal(t, "содержимое", string(content))
require.NoError(t, work.Close())
_, err = os.Stat(work.Path())
assert.True(t, os.IsNotExist(err), "закрытая копия убрана")
}
// Уборка рабочей копии проверяется и на шаге конвертации: репозиторий даёт
// единственный способ убрать копию, но зовёт его шаг, и норма держится
// проверкой, а не построением. Копий здесь две — исходник и результат.
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
newTelegramJob(t, env)
// Приём уже отработал — убеждаемся, что за собой он прибрал, иначе остаток
// от него зачёлся бы шагу конвертации.
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
require.NoError(t, env.service.FindAndRunConversionJob())
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
}
+267
View File
@@ -0,0 +1,267 @@
package service
import (
"errors"
"io"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут
// результат условием по держателю захвата. Подставной распознаватель проекта
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания,
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый
// двойник.
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст.
type scriptedRecognizer struct {
result *entity.RecognitionResult
text string
recognizeErr error
recognizeCalls int
lastObjectKey string
}
func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string, error) {
r.recognizeCalls++
r.lastObjectKey = fileName
if r.recognizeErr != nil {
return "", r.recognizeErr
}
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
if _, err := io.Copy(io.Discard, file); err != nil {
return "", err
}
return "operation-id", nil
}
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) {
return r.text, nil
}
func (r *scriptedRecognizer) CheckRecognitionStatus(string) (*entity.RecognitionResult, error) {
return r.result, nil
}
// convertedJob доводит задачу до состояния, с которого работает шаг
// распознавания: запись принята и сконвертирована.
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
job := newTelegramJob(t, env)
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour))
require.NoError(t, err)
acquired.MoveToState(entity.StateConverted)
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
return job
}
// withRecognizer пересобирает сервис с управляемым распознавателем поверх того
// же хранилища.
func withRecognizer(env *pipelineEnv, rec contract.AudioRecognizer) *TranscribeService {
return NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
env.sender,
env.service.logger,
)
}
// Шаг распознавания отдаёт содержимое наружу, заводит запись о копии во внешнем
// хранилище и переставляет на неё ссылку задачи — только после того, как запись
// о копии существует.
func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := convertedJob(t, env)
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeJob())
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
require.NotNil(t, after.RecognitionOpID)
assert.Equal(t, "operation-id", *after.RecognitionOpID)
require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена")
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную.
require.NotNil(t, after.FileID)
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
require.NoError(t, err)
assert.Equal(t, entity.LocationS3, copyRecord.Location)
}
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору.
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := convertedJob(t, env)
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
svc := withRecognizer(env, rec)
require.Error(t, svc.FindAndRunTranscribeJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена")
assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут")
}
// transcribingJob доводит задачу до состояния ожидания операции.
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob {
t.Helper()
job := convertedJob(t, env)
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob())
clearDelay(t, env, job.Id)
return job
}
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
// без отказа, и задержка у него своя, числом.
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
svc := withRecognizer(env, rec)
for i := 0; i < 3; i++ {
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
require.NotNil(t, after.DelayTime)
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2,
"задержка опроса не выродилась в наименьшую паузу повтора")
clearDelay(t, env, job.Id)
}
}
// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а
// отправитель узнаёт причину человеческим текстом.
func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewFailedResult("операция отклонена")
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.Len(t, env.sender.messages, 1, "отправитель узнал об отказе")
assert.Contains(t, env.sender.messages[0], "сбой при распознавании файла")
assert.NotContains(t, env.sender.messages[0], "операция отклонена",
"машинная причина отправителю не идёт")
}
// Готовая операция завершает задачу и отдаёт текст отправителю ровно один раз.
func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText)
require.Len(t, env.sender.messages, 1, "отправитель получил ровно один ответ")
assert.Equal(t, "расшифровка записи", env.sender.messages[0])
// И задача из выборки исчезла: второй ответ отправителю неоткуда взяться.
_, err = env.jobRepo.FindAndAcquire(entity.StateTranscribe, "next", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
}
// Пустая расшифровка — не отказ: задача завершается, а отправителю уходит
// объяснение вместо пустого сообщения.
func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = ""
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.Len(t, env.sender.messages, 1)
assert.Contains(t, strings.ToLower(env.sender.messages[0]), "нет текста")
}
// Шаг, потерявший захват за время работы, результата не пишет и отправителю не
// отвечает: иначе два воркера пишут в одну задачу, а отправитель получает два
// ответа на одну запись.
func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
// Захват задачи достался другому, пока шаг работал.
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
record, err := env.app.FindRecordById(pocketbase.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, env.app.Save(record))
svc := withRecognizer(env, rec)
err = svc.checkTranscribeJob(acquired, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
}
+268 -192
View File
@@ -5,9 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os" "math"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -19,17 +18,39 @@ import (
const ( const (
defaultAudioExt = "audio" defaultAudioExt = "audio"
// Предел попыток. Число обратимо и живёт здесь одним местом; счётчик растёт
// при захвате и обнуляется на шаге, завершившемся без отказа.
maxAttempts = 5
// Пауза перед повтором отказавшей задачи растёт с числом попыток до
// потолка. Ожидание чужой операции этой паузой не выражается — у него своя
// задержка числом, и попытки оно не тратит.
retryDelayBase = time.Second
retryDelayCap = 5 * time.Minute
// Сроки захвата. Каждый не меньше того, что его шаг может занять на самом
// длинном допустимом входе: расчётный потолок записи — шесть часов, и
// конвертация такой записи идёт дольше часа по построению.
conversionAcquireTimeout = 8 * time.Hour
transcribeAcquireTimeout = 8 * time.Hour
checkAcquireTimeout = time.Hour
// Задержки опроса операции распознавания. Числа, а не функция числа попыток:
// счётчик на ожидании обнулён, и выведенная из него пауза выродилась бы в
// своё наименьшее значение, учащая опрос платного сервиса.
firstCheckDelay = 10 * time.Second
nextCheckDelay = 5 * time.Second
) )
type TranscribeService struct { type TranscribeService struct {
jobRepo contract.TranscriptJobRepository jobRepo contract.TranscriptJobRepository
fileRepo contract.FileRepository fileRepo contract.FileRepository
metaviewer contract.AudioMetaViewer metaviewer contract.AudioMetaViewer
converter contract.AudioFileConverter converter contract.AudioFileConverter
recognizer contract.AudioRecognizer recognizer contract.AudioRecognizer
tgSender contract.TelegramMessageSender tgSender contract.TelegramMessageSender
storagePath string logger *slog.Logger
logger *slog.Logger
} }
func NewTranscribeService( func NewTranscribeService(
@@ -39,220 +60,212 @@ func NewTranscribeService(
converter contract.AudioFileConverter, converter contract.AudioFileConverter,
recognizer contract.AudioRecognizer, recognizer contract.AudioRecognizer,
tgSender contract.TelegramMessageSender, tgSender contract.TelegramMessageSender,
storagePath string,
logger *slog.Logger, logger *slog.Logger,
) *TranscribeService { ) *TranscribeService {
return &TranscribeService{ return &TranscribeService{
jobRepo: jobRepo, jobRepo: jobRepo,
fileRepo: fileRepo, fileRepo: fileRepo,
metaviewer: metaviewer, metaviewer: metaviewer,
converter: converter, converter: converter,
recognizer: recognizer, recognizer: recognizer,
tgSender: tgSender, tgSender: tgSender,
storagePath: storagePath, logger: logger,
logger: logger,
} }
} }
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) { func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
jobId := uuid.NewString()
now := time.Now()
job := &entity.TranscribeJob{ job := &entity.TranscribeJob{
Id: jobId,
State: entity.StateCreated, State: entity.StateCreated,
Source: entity.SourceTelegram, Source: entity.SourceTelegram,
TgChatId: &chatId, TgChatId: &chatId,
TgReplyMessageId: &replyMsgId, TgReplyMessageId: &replyMsgId,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
} }
return s.createTranscribeJob(job, file, fileName) return s.createTranscribeJob(job, file, fileName)
} }
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) { func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
jobId := uuid.NewString()
now := time.Now()
job := &entity.TranscribeJob{ job := &entity.TranscribeJob{
Id: jobId, State: entity.StateCreated,
State: entity.StateCreated, Source: entity.SourceApi,
Source: entity.SourceApi,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
} }
return s.createTranscribeJob(job, file, fileName) return s.createTranscribeJob(job, file, fileName)
} }
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) { func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
// Генерируем UUID для файла
fileId := uuid.NewString()
// Определяем расширение файла // Определяем расширение файла
ext := filepath.Ext(fileName) ext := filepath.Ext(fileName)
if ext == "" { if ext == "" {
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
} }
// Создаем путь для сохранения файла // Собственное имя записи: идентификатор с расширением. Имя, данное
// отправителем, в хранилище не попадает — от него взято только расширение.
fileId := uuid.NewString()
storageFileName := fmt.Sprintf("%s%s", fileId, ext) storageFileName := fmt.Sprintf("%s%s", fileId, ext)
storageFilePath := filepath.Join(s.storagePath, storageFileName)
s.logger.Info("Creating transcribe job", // Содержимое ложится в рабочую копию потоком: в память запись целиком не
"file_id", fileId, // читается, расчётный потолок — шесть часов.
"file_name", fileName, work, err := s.fileRepo.Stage(ext, file)
"storage_path", storageFilePath)
// Создаем файл на диске
dst, err := os.Create(storageFilePath)
if err != nil { if err != nil {
s.logger.Error("Failed to create file", "error", err, "path", storageFilePath) s.logger.Error("Failed to stage uploaded file", "error", err)
return nil, err return nil, err
} }
defer dst.Close() defer s.closeWork(work)
// Копируем содержимое загруженного файла // В журнал идёт расширение, и только оно. Имя, данное отправителем, не
size, err := io.Copy(dst, file) // пишется по инварианту приватности; имя, под которым файл ложится в
// хранилище, — потому что оно последняя часть ссылки на скачивание, и
// строка журнала вместе с идентификатором записи собрала бы её целиком.
s.logger.Info("Creating transcribe job", "file_ext", ext)
info, err := s.metaviewer.GetInfo(work.Path())
if err != nil { if err != nil {
s.logger.Error("Failed to copy file content", "error", err) s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
return nil, err return nil, err
} }
if err := dst.Close(); err != nil { size, err := work.Size()
s.logger.Error("Failed to close file", "error", err) if err != nil {
s.logger.Error("Failed to measure uploaded file", "error", err)
return nil, err return nil, err
} }
info, err := s.metaviewer.GetInfo(storageFilePath) fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work)
if err != nil { if err != nil {
s.logger.Error("Failed to get file info", "error", err, "path", storageFilePath) s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
return nil, err return nil, err
} }
s.logger.Info("File uploaded successfully", s.logger.Info("File uploaded successfully",
"file_id", fileId, "file_id", fileRecord.Id,
"size", size, "size", size,
"duration_seconds", info.Seconds) "duration_seconds", info.Seconds)
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds)) metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
metrics.InputFileSizeHistogram.WithLabelValues(ext).Observe(float64(size)) metrics.ObserveInputFileSize(ext, size)
// Создаем запись в таблице files job.FileID = &fileRecord.Id
fileRecord := &entity.File{
Id: fileId,
Storage: entity.StorageLocal,
FileName: storageFileName,
Size: size,
CreatedAt: time.Now(),
}
if err := s.fileRepo.Create(fileRecord); err != nil {
// Удаляем файл если не удалось создать запись в БД
os.Remove(storageFilePath)
s.logger.Error("Failed to create file record", "error", err, "file_id", fileId)
return nil, err
}
job.FileID = &fileId
if err := s.jobRepo.Create(job); err != nil { if err := s.jobRepo.Create(job); err != nil {
s.logger.Error("Failed to create job record", "error", err, "job_id", job.Id) s.logger.Error("Failed to create job record", "error", err, "file_id", fileRecord.Id)
return nil, err return nil, err
} }
s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileId) s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileRecord.Id)
return job, nil return job, nil
} }
func (s *TranscribeService) FindAndRunConversionJob() error { func (s *TranscribeService) FindAndRunConversionJob() error {
job, err := s.findJob(entity.StateCreated, time.Hour) return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob)
}
func (s *TranscribeService) FindAndRunTranscribeJob() error {
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
}
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
}
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
// — иначе повтор наступал бы через восемь часов, а не через секунду.
func (s *TranscribeService) runStep(state string, expiration time.Duration, step func(job *entity.TranscribeJob, holder string) error) error {
job, holder, err := s.findJob(state, expiration)
if err != nil { if err != nil {
return err return err
} }
if err := step(job, holder); err != nil {
s.scheduleRetry(job, holder, err)
return err
}
return nil
}
func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string) error {
s.logger.Info("Starting conversion job", "job_id", job.Id) s.logger.Info("Starting conversion job", "job_id", job.Id)
if job.FileID == nil {
s.logger.Error("Job has no file", "job_id", job.Id)
return s.failJob(job, holder, errors.New("job has no file"), "у задачи нет записи")
}
srcFile, err := s.fileRepo.GetByID(*job.FileID) srcFile, err := s.fileRepo.GetByID(*job.FileID)
if err != nil { if err != nil {
s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID) s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID)
return err return err
} }
srcFilePath := filepath.Join(s.storagePath, srcFile.FileName)
destFileId := uuid.NewString()
destFileName := fmt.Sprintf("%s%s", destFileId, ".ogg")
destFilePath := filepath.Join(s.storagePath, destFileName)
// Получаем расширение исходного файла для метрики // Получаем расширение исходного файла для метрики
srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".") srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".")
if srcExt == "" { if srcExt == "" {
srcExt = defaultAudioExt srcExt = defaultAudioExt
} }
s.logger.Info("Converting file", src, err := s.fileRepo.Localize(*job.FileID)
"job_id", job.Id, if err != nil {
"src_path", srcFilePath, s.logger.Error("Failed to localize source file", "error", err, "file_id", *job.FileID)
"dest_path", destFilePath, return err
"src_format", srcExt) }
defer s.closeWork(src)
dest, err := s.fileRepo.StageEmpty(".ogg")
if err != nil {
s.logger.Error("Failed to stage converted file", "error", err, "job_id", job.Id)
return err
}
defer s.closeWork(dest)
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
// Измеряем время конвертации // Измеряем время конвертации
startTime := time.Now() startTime := time.Now()
err = s.converter.Convert(srcFilePath, destFilePath) err = s.converter.Convert(src.Path(), dest.Path())
conversionDuration := time.Since(startTime) conversionDuration := time.Since(startTime)
// Записываем метрику времени конвертации // Записываем метрику времени конвертации
metrics.ConversionDurationHistogram. metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
WithLabelValues(srcExt, "ogg", strconv.FormatBool(err != nil)).
Observe(conversionDuration.Seconds())
if err != nil { if err != nil {
s.logger.Error("File conversion failed", s.logger.Error("File conversion failed",
"error", err, "error", err,
"job_id", job.Id, "job_id", job.Id,
"duration", conversionDuration) "duration", conversionDuration)
return s.failJob(job, err, "сбой конвертации файла") return s.failJob(job, holder, err, "сбой конвертации файла")
} }
stat, err := os.Stat(destFilePath) destSize, err := dest.Size()
if err != nil { if err != nil {
s.logger.Error("Failed to stat converted file", "error", err, "path", destFilePath) s.logger.Error("Failed to measure converted file", "error", err, "job_id", job.Id)
return err return err
} }
s.logger.Info("File conversion completed", s.logger.Info("File conversion completed",
"job_id", job.Id, "job_id", job.Id,
"duration", conversionDuration, "duration", conversionDuration,
"output_size", stat.Size()) "output_size", destSize)
// Записываем метрику размера выходного файла // Записываем метрику размера выходного файла
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(stat.Size())) metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
// Создаем запись в таблице files destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
destFileRecord := &entity.File{ destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest)
Id: destFileId,
Storage: entity.StorageLocal,
FileName: destFileName,
Size: stat.Size(),
CreatedAt: time.Now(),
}
job.FileID = &destFileId
job.MoveToState(entity.StateConverted)
err = s.fileRepo.Create(destFileRecord)
if err != nil { if err != nil {
s.logger.Error("Failed to create converted file record", "error", err, "file_id", destFileId) s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id)
return err return err
} }
err = s.jobRepo.Save(job) // Ссылка переставляется только после того, как запись о новом файле есть:
if err != nil { // иначе повтор оставил бы задачу указывающей на файл, которого нет.
job.FileID = &destFileRecord.Id
job.MoveToState(entity.StateConverted)
if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err return err
} }
@@ -261,36 +274,35 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
return nil return nil
} }
func (s *TranscribeService) FindAndRunTranscribeJob() error { func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
job, err := s.findJob(entity.StateConverted, time.Hour)
if err != nil {
return err
}
s.logger.Info("Starting transcribe job", "job_id", job.Id) s.logger.Info("Starting transcribe job", "job_id", job.Id)
if job.FileID == nil {
s.logger.Error("Job has no file", "job_id", job.Id)
return s.failJob(job, holder, errors.New("job has no file"), "у задачи нет записи")
}
fileRecord, err := s.fileRepo.GetByID(*job.FileID) fileRecord, err := s.fileRepo.GetByID(*job.FileID)
if err != nil { if err != nil {
s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID) s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID)
return err return err
} }
filePath := filepath.Join(s.storagePath, fileRecord.FileName) content, err := s.fileRepo.Open(*job.FileID)
file, err := os.Open(filePath)
if err != nil { if err != nil {
s.logger.Error("Failed to open file", "error", err, "path", filePath) s.logger.Error("Failed to open file", "error", err, "file_id", *job.FileID)
return err return err
} }
defer file.Close() defer func() {
if err := content.Close(); err != nil {
s.logger.Error("Failed to close file", "error", err, "file_id", *job.FileID)
}
}()
destFileId := uuid.NewString() s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
s.logger.Info("Starting recognition", "job_id", job.Id, "file_path", filePath)
// Запускаем асинхронное распознавание // Запускаем асинхронное распознавание
operationID, err := s.recognizer.Recognize(file, destFileRecord.FileName) operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
if err != nil { if err != nil {
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id) s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
return err return err
@@ -300,20 +312,19 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
"job_id", job.Id, "job_id", job.Id,
"operation_id", operationID) "operation_id", operationID)
// Обновляем задачу с ID операции распознавания destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size)
job.FileID = &destFileId
job.RecognitionOpID = &operationID
delayTime := time.Now().Add(10 * time.Second)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
err = s.fileRepo.Create(destFileRecord)
if err != nil { if err != nil {
s.logger.Error("Failed to create S3 file record", "error", err, "file_id", destFileId) s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id)
return err return err
} }
err = s.jobRepo.Save(job) // Обновляем задачу с ID операции распознавания
if err != nil { job.FileID = &destFileRecord.Id
job.RecognitionOpID = &operationID
delayTime := time.Now().Add(firstCheckDelay)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err return err
} }
@@ -322,12 +333,7 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
return nil return nil
} }
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error { func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
job, err := s.findJob(entity.StateTranscribe, 24*time.Hour)
if err != nil {
return err
}
if job.RecognitionOpID == nil { if job.RecognitionOpID == nil {
s.logger.Error("Recognition operation ID not found", "job_id", job.Id) s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
return fmt.Errorf("recogniton opId not found for job: %s", job.Id) return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
@@ -344,12 +350,13 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
} }
if recResult.IsInProgress() { if recResult.IsInProgress() {
// Операция еще не завершена, оставляем в статусе обработки // Операция ещё не завершена. Шаг отработал без отказа, поэтому задержка
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
// чужой операции попытку не тратит.
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId) s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
delayTime := time.Now().Add(5 * time.Second) delayTime := time.Now().Add(nextCheckDelay)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime) job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
err := s.jobRepo.Save(job) if err := s.jobRepo.Save(job, holder); err != nil {
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err return err
} }
@@ -362,7 +369,7 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
"job_id", job.Id, "job_id", job.Id,
"operation_id", opId, "operation_id", opId,
"error_message", errorText) "error_message", errorText)
return s.failJob(job, errors.New(errorText), "сбой при распознавании файла") return s.failJob(job, holder, errors.New(errorText), "сбой при распознавании файла")
} }
// Операция завершена, получаем результат // Операция завершена, получаем результат
@@ -378,83 +385,152 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
"text_length", len(transcriptionText)) "text_length", len(transcriptionText))
if len(transcriptionText) == 0 { if len(transcriptionText) == 0 {
return s.completeJob(job, "Ой, кажется, на аудиозаписи нет текста.") return s.completeJob(job, holder, "Ой, кажется, на аудиозаписи нет текста.")
} }
// Завершаем задачу // Завершаем задачу
return s.completeJob(job, transcriptionText) return s.completeJob(job, holder, transcriptionText)
} }
func (s *TranscribeService) findJob(state string, expiration time.Duration) (job *entity.TranscribeJob, err error) { // findJob забирает задачу и отдаёт её вместе с признаком захвата, который шаг
// держит. Задача, захваченная сверх предела попыток, до шага не доходит: её
// переводят в «мертва» и сообщают об этом отправителю.
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
acquisitionId := uuid.NewString() acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * expiration) rottingTime := time.Now().Add(-1 * expiration)
job, err = s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime) job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
if err != nil { if err != nil {
if _, ok := err.(*contract.JobNotFoundError); ok { // Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ
return nil, &contract.NoopJobError{State: state} // пояснением, и приведение типа от этого сломалось бы молча.
var notFound *contract.JobNotFoundError
if errors.As(err, &notFound) {
return nil, "", &contract.NoopJobError{State: state}
} }
s.logger.Error("Failed to find and acquire job", "state", state, "error", err) s.logger.Error("Failed to find and acquire job", "state", state, "error", err)
return nil, fmt.Errorf("failed find and acquire job: %s, %w", state, err) return nil, "", fmt.Errorf("failed find and acquire job: %s, %w", state, err)
} }
return job, nil if job.Attempts > maxAttempts {
s.killJob(job, acquisitionId)
return nil, "", &contract.NoopJobError{State: state}
}
return job, acquisitionId, nil
} }
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, transcriptionText string) error { // killJob переводит исчерпавшую попытки задачу в «мертва» и сообщает об этом
// отправителю. Инвариант «Принятая запись не теряется молча» допускает два
// исхода — задача пригодна к повтору либо об отказе сказано, — и молчаливая
// смерть не подходит ни под один.
func (s *TranscribeService) killJob(job *entity.TranscribeJob, holder string) {
s.logger.Error("Job exhausted its attempts",
"job_id", job.Id,
"state", job.State,
"attempts", job.Attempts)
job.Die(fmt.Sprintf("attempts exhausted: %d", job.Attempts))
if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save dead job", "error", err, "job_id", job.Id)
return
}
s.notify(job, "Не удалось обработать запись: попытки исчерпаны.\nПожалуйста, попробуйте еще раз.")
}
// scheduleRetry снимает захват с отказавшей задачи и ставит нарастающую паузу.
// Захват, оставленный до конца срока, отложил бы повтор на часы.
func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder string, stepErr error) {
// Шаг, потерявший захват, задачу уже не трогает: ею занят другой.
var lost *contract.LostAcquisitionError
if errors.As(stepErr, &lost) {
return
}
job.RetryAfter(time.Now().Add(retryDelay(job.Attempts)))
if err := s.jobRepo.Save(job, holder); err != nil {
var lostOnSave *contract.LostAcquisitionError
if errors.As(err, &lostOnSave) {
return
}
s.logger.Error("Failed to schedule job retry", "error", err, "job_id", job.Id)
}
}
// retryDelay растит паузу с числом попыток до потолка.
func retryDelay(attempts int) time.Duration {
if attempts < 1 {
attempts = 1
}
delay := time.Duration(math.Pow(2, float64(attempts-1))) * retryDelayBase
if delay > retryDelayCap || delay <= 0 {
return retryDelayCap
}
return delay
}
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, holder string, transcriptionText string) error {
// Обновляем задачу с результатом // Обновляем задачу с результатом
job.Done(transcriptionText) job.Done(transcriptionText)
// Сохраняем задачу в базу // Сохраняем задачу в базу
err := s.jobRepo.Save(job) if err := s.jobRepo.Save(job, holder); err != nil {
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return fmt.Errorf("failed to save job: %w", err) return fmt.Errorf("failed to save job: %w", err)
} }
// Отправляем распознанный текст обратно пользователю // Отправляем распознанный текст обратно пользователю
switch job.Source { return s.send(job, transcriptionText)
case entity.SourceTelegram:
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
}
err := s.tgSender.Send(transcriptionText, *job.TgChatId, job.TgReplyMessageId)
if err != nil {
s.logger.Error("Failed to sent transcription text to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
}
}
return nil
} }
func (s *TranscribeService) failJob(job *entity.TranscribeJob, jobErr error, humanErrorText string) error { func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jobErr error, humanErrorText string) error {
// Обновляем задачу с результатом // Обновляем задачу с результатом
job.Fail(jobErr.Error()) job.Fail(jobErr.Error())
// Сохраняем задачу в базу // Сохраняем задачу в базу
err := s.jobRepo.Save(job) if err := s.jobRepo.Save(job, holder); err != nil {
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return fmt.Errorf("failed to save job: %w", err) return fmt.Errorf("failed to save job: %w", err)
} }
// Отправляем текст об ошибке пользователю errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
switch job.Source { return s.send(job, errorMessage)
case entity.SourceTelegram: }
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
}
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText) // send отвечает отправителю там, откуда пришла запись, и отказ отправки
err := s.tgSender.Send(errorMessage, *job.TgChatId, job.TgReplyMessageId) // поднимает вверх: он принадлежит шагу.
if err != nil { func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
s.logger.Error("Failed to sent message to client", "job_id", job.Id) if job.Source != entity.SourceTelegram {
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err) return nil
} }
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
}
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
} }
return nil return nil
} }
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже
// доведена до конца, и отказ отправки остаётся записью в журнале владельца.
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
if err := s.send(job, text); err != nil {
s.logger.Error("Failed to notify sender", "error", err, "job_id", job.Id)
}
}
// closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не
// проглатывается: забытая копия это шестичасовая запись во временном каталоге.
func (s *TranscribeService) closeWork(work contract.WorkFile) {
if err := work.Close(); err != nil {
s.logger.Error("Failed to remove work file", "error", err)
}
}
+98 -96
View File
@@ -2,8 +2,7 @@ package main
import ( import (
"context" "context"
"database/sql" "errors"
"embed"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -17,29 +16,17 @@ import (
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg" ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram" "git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3" "github.com/pocketbase/pocketbase/apis"
"github.com/pressly/goose/v3" "github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
sloggin "github.com/samber/slog-gin"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
const (
ServerShutdownTimeout = 5
ForceShutdownTimeout = 20
) )
func main() { func main() {
@@ -68,35 +55,25 @@ func main() {
logger.Warn("Warning: .env file not found, using system environment variables") logger.Warn("Warning: .env file not found, using system environment variables")
} }
// Создаем директории если они не существуют // Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil { // и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
logger.Error("Failed to create file storage directory", "path", cfg.Storage.Path, "error", err) // непринятые шаги прежде, чем поднять сервер.
os.Exit(1) storage, err := pbrepo.New(cfg.Storage.DataDir)
}
db, err := sql.Open("sqlite3", cfg.Database.Path)
if err != nil { if err != nil {
logger.Error("failed to open database", "error", err) logger.Error("Failed to open storage", "error", err)
os.Exit(1) os.Exit(1)
} }
defer db.Close() defer func() {
if err := storage.ResetBootstrapState(); err != nil {
logger.Error("Failed to close storage", "error", err)
}
}()
if err := db.Ping(); err != nil { pbrepo.BindPanelRules(storage)
logger.Error("failed to ping database", "error", err)
os.Exit(1)
}
gq := goqu.New("sqlite3", db)
// Запускаем миграции
if err := RunMigrations(db, logger); err != nil {
logger.Error("Failed to run migrations", "error", err)
os.Exit(1)
}
// Создаем репозитории // Создаем репозитории
fileRepo := sqlite.NewFileRepository(db, gq) fileRepo := pbrepo.NewFileRepository(storage)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq) jobRepo := pbrepo.NewTranscriptJobRepository(storage)
// Создаем адаптеры // Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
@@ -121,7 +98,15 @@ func main() {
logger.Error("failed to create audio recognizer", "error", err) logger.Error("failed to create audio recognizer", "error", err)
os.Exit(1) os.Exit(1)
} }
defer recognizer.Close() // Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
// при повторном закрытии, то есть запись говорит о нашей ошибке, а не о
// недоступности Yandex.
defer func() {
if err := recognizer.Close(); err != nil {
logger.Error("failed to close audio recognizer", "error", err)
}
}()
// Создаем сервисы // Создаем сервисы
transcribeService := service.NewTranscribeService( transcribeService := service.NewTranscribeService(
@@ -131,7 +116,6 @@ func main() {
converter, converter,
recognizer, recognizer,
tgSender, tgSender,
cfg.Storage.Path,
logger, logger,
) )
@@ -185,49 +169,74 @@ func main() {
}(w) }(w)
} }
// Создаем Gin middleware для логирования // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
gin.SetMode(gin.DebugMode) // и второму серверу на нём взяться неоткуда.
router := gin.New() transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
router.Use(sloggin.New(logger))
router.Use(gin.Recovery())
// Запускаем HTTP сервер для API (создание задач и проверка статуса) // Сервер приезжает каналом, а не общей переменной: хук исполняется в
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService) // горутине сервера, а читает его горутина остановки, и связи «произошло
// раньше» между ними иначе нет.
srvCh := make(chan *http.Server, 1)
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к
// целенаправленной нагрузке объявлена вне модели угроз проекта.
se.Server.ReadTimeout = 0
srvCh <- se.Server
// Настраиваем роуты только для создания задач и проверки статуса // Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
api := router.Group("/api") // `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
{ // журнале контейнера не видно. Поля — те, что просит конвенция.
api.POST("/audio", transcribeHandler.CreateTranscribeJob) se.Router.BindFunc(func(e *core.RequestEvent) error {
api.GET("/status/:id", transcribeHandler.GetTranscribeJobStatus) start := time.Now()
} err := e.Next()
// Добавляем middleware для обработки больших файлов level := slog.LevelInfo
router.MaxMultipartMemory = 32 << 20 // 32 MiB if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" {
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
level = slog.LevelDebug
}
// Добавляем базовый роут для проверки работоспособности logger.Log(e.Request.Context(), level, "Incoming request",
router.GET("/health", func(c *gin.Context) { "http.method", e.Request.Method,
c.JSON(200, gin.H{ "http.route", e.Request.URL.Path,
"status": "ok", "http.status_code", e.Status(),
"message": "Transcriber service is running", "duration_ms", time.Since(start).Milliseconds(),
"transport", "http")
return err
}) })
transcribeHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"status": "ok",
"message": "Transcriber service is running",
})
})
se.Router.GET("/metrics", func(e *core.RequestEvent) error {
promhttp.Handler().ServeHTTP(e.Response, e.Request)
return nil
})
return se.Next()
}) })
// Добавляем эндпоинт для метрик Prometheus
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
// Создаем HTTP сервер
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
}
// Запускаем HTTP сервер в отдельной горутине // Запускаем HTTP сервер в отдельной горутине
serveErr := make(chan error, 1)
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
logger.Info("Starting HTTP server", "port", cfg.Server.Port) logger.Info("Starting HTTP server", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { err := apis.Serve(storage, apis.ServeConfig{
HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port),
ShowStartBanner: false,
})
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("HTTP server error", "error", err) logger.Error("HTTP server error", "error", err)
serveErr <- err
} }
}() }()
@@ -239,9 +248,13 @@ func main() {
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker") logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
logger.Info("Press Ctrl+C to stop...") logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения // Ждем сигнал завершения либо отказ сервера
<-sigChan select {
logger.Info("Received shutdown signal, initiating graceful shutdown...") case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...")
case <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down")
}
if tgController != nil { if tgController != nil {
logger.Info("Shutting down Telegram bot...") logger.Info("Shutting down Telegram bot...")
@@ -253,11 +266,16 @@ func main() {
defer shutdownCancel() defer shutdownCancel()
// Останавливаем HTTP сервер // Останавливаем HTTP сервер
logger.Info("Shutting down HTTP server...") select {
if err := srv.Shutdown(shutdownCtx); err != nil { case srv := <-srvCh:
logger.Error("HTTP server forced to shutdown", "error", err) logger.Info("Shutting down HTTP server...")
} else { if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Info("HTTP server stopped gracefully") logger.Error("HTTP server forced to shutdown", "error", err)
} else {
logger.Info("HTTP server stopped gracefully")
}
default:
logger.Info("HTTP server was not started, nothing to shut down")
} }
// Отменяем контекст для остановки воркеров // Отменяем контекст для остановки воркеров
@@ -280,19 +298,3 @@ func main() {
logger.Info("Transcriber service stopped") logger.Info("Transcriber service stopped")
} }
func RunMigrations(db *sql.DB, logger *slog.Logger) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
// Use the embedded filesystem for migrations
goose.SetBaseFS(migrationsFS)
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
logger.Info("Migrations completed successfully")
return nil
}
-11
View File
@@ -1,11 +0,0 @@
-- +goose Up
CREATE TABLE files (
id TEXT PRIMARY KEY,
storage TEXT NOT NULL,
file_name TEXT NOT NULL,
size INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- +goose Down
DROP TABLE files;
@@ -1,24 +0,0 @@
-- +goose Up
CREATE TABLE transcribe_jobs (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
delay_time DATETIME,
file_id TEXT,
recognition_op_id TEXT,
transcription_text TEXT,
acquisition_id TEXT,
acquire_time DATETIME,
is_error BOOLEAN NOT NULL,
error_text TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (file_id) REFERENCES files(id)
);
-- +goose Down
DROP TABLE transcribe_jobs;
@@ -1,9 +0,0 @@
-- +goose Up
ALTER TABLE transcribe_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE transcribe_jobs ADD COLUMN tg_chat_id INTEGER;
ALTER TABLE transcribe_jobs ADD COLUMN tg_reply_message_id INTEGER;
-- +goose Down
ALTER TABLE transcribe_jobs DROP COLUMN source;
ALTER TABLE transcribe_jobs DROP COLUMN tg_chat_id;
ALTER TABLE transcribe_jobs DROP COLUMN tg_reply_message_id;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,218 @@
## Context
Два признака конвейера — «работы в этом состоянии нет» и «подходящей задачи не
нашлось» — сегодня узнаются приведением значения ошибки к точному типу:
`err.(*contract.NoopJobError)` в `internal/controller/worker/worker.go:51` и
`err.(*contract.JobNotFoundError)` в `internal/service/transcribe.go:392`.
Приведение видит только само значение и слепо к пояснениям, добавленным
обёрткой `fmt.Errorf("…: %w", err)`.
Путь сегодня короткий и обёрток на нём нет: `FindAndAcquire`
(`internal/adapter/repo/sqlite/transcript_job_repo.go:177`) рождает
`JobNotFoundError`, `findJob` переводит его в `NoopJobError`, воркер этот признак
ловит. Поэтому дефект пока не проявился — он ждёт первой обёртки, а обёртка
`%w` объявлена конвенцией `docs/conventions/errors.md` умолчанием проекта. То
есть код написан против собственного умолчания, и цена срабатывания —
три записи отказа в секунду и столько же засчитанных сбоев, которых не было.
Отдельно и мельче: отказ закрытия соединения теряется в двух местах —
`sttConn.Close()` на пути отказа конструктора распознавателя
(`internal/adapter/recognizer/yandex/speechkit.go:55`) и `defer
recognizer.Close()` при остановке процесса (`main.go:124`).
Оба сюжета держат гейт проекта красным четырьмя замечаниями линтера; долг
объявлен в `CLAUDE.md`, разделе «Гейт».
## Goals / Non-Goals
**Goals:**
- Признак пустого прогона переживает пояснение, добавленное на любом
промежуточном шаге.
- Отказ закрытия соединения не теряется молча.
- Гейт зелёный целиком: замечаний `errorlint` и `errcheck` нет.
**Non-Goals:**
- Внешнее поведение не меняется: ни ответы пользователю, ни набор состояний
задачи, ни формат метрик.
- Единая точка перевода доменной ошибки в HTTP-статус — другое расхождение той
же конвенции, записанное там же; этим изменением не трогается.
- Обёртки `%w` по всему пути не расставляются: изменение делает проверку
устойчивой к ним, а не вводит их.
- `tg.EmptyBotTokenError` — третья типизированная ошибка без полей — не трогается:
линтер на ней молчит, приведения типа у неё нет.
## Decisions
### Решение 1: признак узнаётся `errors.As`, типы остаются
Обе проверки переходят на `errors.As` с сохранением сегодняшних типов
`contract.NoopJobError` и `contract.JobNotFoundError`.
Что человек увидит иначе: ничего — ровно в этом ценность. Владелец сервиса
увидит разницу лишь в тот день, когда кто-то добавит пояснение к ошибке на этом
пути: журнал останется тихим, вместо того чтобы наполниться отказами, которых
не было.
**Рассмотрено и отвергнуто — sentinel вместо типов.** Конвенция
(`docs/conventions/errors.md`, раздел «Sentinel и типизированные») говорит:
типизированная ошибка нужна, когда вызывающему нужны **данные**, а данные обоих
типов сегодня не читает никто — обе проверяются на факт. По этому доводу типы
следовало бы заменить на `errors.New` и проверять `errors.Is`, а состояние
задачи вносить обёрткой `fmt.Errorf("%s: %w", state, contract.ErrNoJob)`.
Отвергнуто по цене против цели: обе формы **одинаково** устойчивы к обёртке, то
есть по цели изменения они неразличимы, а sentinel правит пять мест вместо двух
и переписывает рождение ошибки в репозитории и в сервисе. Задача — снятие долга
линтера, а не пересмотр номенклатуры ошибок. Довод конвенции при этом не
исчезает: он остаётся верным и становится поводом отдельной работы в тот день,
когда типы так и не начнут нести читаемых данных.
**Рассмотрено и отвергнуто — оставить приведение, заглушив линтер.** Правило
`errorlint` выключается директивой на строке. Отвергнуто: дефект от этого не
исчезает, а перестаёт быть видимым, и следующий читатель кода примет молчание
линтера за проверенность.
### Решение 2: отказ закрытия — по месту, а не единым правилом
Два места разные по природе, и одинаково они не чинятся.
- **Путь отказа конструктора** (`speechkit.go:55`): клиент распознавания создан,
а клиент операций создать не удалось. Оба отказа независимы, и конвенция для
такого случая называет `errors.Join`. Ошибка конструктора получает вторую
строку, если закрытие тоже отказало.
- **Остановка процесса** (`main.go:124`): отдавать отказ некому — процесс
заканчивается. Значит, он идёт в журнал владельца, а `defer` получает тело с
проверкой. В запись идёт **значение ошибки как есть**: оно несёт состояние
клиента и адрес узла, но не тело запроса и не ключ — ключ живёт в метаданных
вызова, а не в соединении. Разворачивать первопричину на границе клиента, как
требует `docs/conventions/logging.md` от ошибок транспорта, здесь нечего:
обёрток на этом пути нет.
**Чем этот отказ является на самом деле — сказано прямо, чтобы обоснование не
поехало дальше в неверном виде.** `grpc.NewClient` ленив и соединения не
открывает, а `Close` у клиента возвращает отказ единственным образом — при
повторном закрытии. Значит, на пути отказа конструктора второй операнд почти
наверняка `nil`, а запись при остановке процесса говорит о **нашей** ошибке
(закрыли дважды), а не о недоступности Yandex. Обработка обоих мест остаётся:
она стоит одну строку и переживёт смену клиента, а её отсутствие каждый раз
приходится заново обосновывать читателю.
**Оракул этого решения чинится машиной, а не обещанием.** Правило `errcheck`
сегодня молчит на `_ = conn.Close()`: настройка `check-blank` не выставлена, а её
умолчание — «пропускать». То есть реализация, выбрасывающая отказ в пустоту,
удовлетворяет критерию приёмки «линтер не даёт замечаний `errcheck`», не
удовлетворяя самому критерию — «отказ возвращается либо попадает в журнал».
Поэтому изменение включает `errcheck.check-blank: true` в `.golangci.yml`.
Проверено прогоном: на сегодняшнем коде правило замечаний не добавляет, то есть
включается чисто и отдельного коммита приведения не требует.
Это правка политики линтера, и её цена названа: `_ =` перестаёт быть способом
сказать «отказ здесь не важен» молча — теперь такое место придётся либо
обрабатывать, либо вносить в `exclude-functions` поимённо, то есть заметно.
**Рассмотрено и отвергнуто — дописать оба типа в `exclude-functions`
`.golangci.yml`.** Соблазн сильный: список исключений там уже есть, и в нём
записана ровно эта политика — «закрытие через `defer` и лучшая-попытка уборки
файла — осознанно без проверки». То есть замечание снимается одной строкой
конфига, и она даже выглядит согласованной с прежним решением.
Отвергнуто: политика в конфиге относится к закрытию, у которого **отказ ничего
не значит** — файл, читатель, соединение с базой на выходе. Здесь не так: в
первом случае отказ закрытия сопровождает уже случившийся отказ и полезен для
разбора, во втором — это единственный признак того, что соединение с оплачиваемым
внешним сервисом закрылось неправильно. Записав их в исключения, мы бы
расширили политику молча, самим фактом добавления строки, и потеряли бы оба
сигнала навсегда. Критерий приёмки задачи требует именно «возвращается либо
попадает в журнал», а не «линтер замолчал».
### Решение 3: узнавание по смыслу шире приведения, и граница ставится нормой
Приведение типа видело **только вершину** цепочки. `errors.As` видит признак на
любой её глубине — в этом и цель, но у расширения есть встречная сторона: отказ,
к которому признак пустого прогона примешался по дороге (обёрткой или
`errors.Join`), воркер зачтёт пустым прогоном. Тогда задача останется в своём
состоянии и будет переопрашиваться раз в секунду без единой записи — тот самый
класс, от которого защищает инвариант «Принятая запись не теряется молча», только
с обратным знаком относительно чинимого дефекта.
Прямого места, где это случается, сегодня нет: признак рождается ровно в двух
местах и никем не оборачивается. Но `%w` объявлен умолчанием проекта, а
`errors.Join` вводит в кодовую базу это же изменение — значит, дыра появится
тихо и не сегодня.
Граница ставится **нормой, а не кодом**: спека требует, чтобы признак рождался
только ответом хранилища на опрос этим же шагом, и запрещает слою сохранять чужой
признак в цепочке своей ошибки.
**Рассмотрено и отвергнуто — научить воркер различать «признак на вершине» от
«признака в глубине».** Отвергнуто по цене: `errors.As` такого различения не
даёт вовсе, пришлось бы либо проверять вершину вручную (то есть вернуть
приведение типа, которое чинится), либо заводить свой обход цепочки. Код
усложняется ради случая, которого сегодня нет ни одного, а защита от него нужна
на входе — при написании нового слоя, — где норма работает, а проверка в рантайме
опоздала бы.
### Решение 4: спека заводится только на пустой прогон
Дельта заводит capability `pipeline` с одним требованием — о пустом прогоне
воркера. Имя предвосхищено маркерами долга в `docs/architecture.md`.
Закрытие соединения требования **не получает**: домена оно не трогает, наружу не
видно. Заводить под него норму значило бы нормировать внутреннюю гигиену —
граница спек проекта проходит не здесь. Судьёй остаётся критерий приёмки, а его
оракул сделан различающим включением `check-blank` (Решение 2), а не оставлен на
слово.
Требование при этом **не закрепляет нормой известный долг журнала.**
`docs/conventions/logging.md` держит расхождение: сбой фонового цикла
записывается дважды — шагом конвейера и следом воркером, — и уровнем `ERROR`
там, где конвенция просит `WARN` для повторяющегося сбоя. Спека говорит «ровно
один раз единственной логирующей точкой» и уровня не называет: иначе следующий,
кто возьмётся закрывать этот долг, обнаружил бы, что убрать вторую запись нельзя
без правки спеки, — и долг стал бы контрактом молча.
Остальное поведение конвейера (переходы состояний, захват, срок протухания)
спекой тоже не описывается: оно этим изменением не трогается, а требование,
написанное без проверки, — предположение, а не норма.
## Risks / Trade-offs
- **`errors.As` требует указателя на указатель, и ошибка формы не ловится
компилятором, а даёт панику в рантайме** → цель объявляется переменной нужного
типа (`var noop *contract.NoopJobError`), а ветка покрывается тестом, который
и есть оракул критерия 2.
- **`defer` не сработает, если процесс уйдёт через `os.Exit`** → проверить, что
между постановкой `defer` и штатным выходом `os.Exit` не вызывается; иначе
запись о закрытии не появится, и это будет тихой потерей того же рода, что
чинится.
- **Типы остаются, и довод конвенции о sentinel остаётся неотработанным** →
назван открытым вопросом, а не забыт; работа отдельная.
- **`check-blank: true` меняет политику для всего проекта, а не для двух мест** →
проверено прогоном: на сегодняшнем коде замечаний не добавляется. Цена в
будущем — осознанное игнорирование отказа придётся объявлять в
`exclude-functions`, а не писать `_ =` по месту.
- **Молчание на пустом прогоне остаётся единственным наблюдаемым состоянием
цикла**: воркер, остановившийся по отмене или не стартовавший вовсе, выглядит
так же, как воркер без работы → изменением не чинится и в требование не
закладывается запрет на будущий признак живости: спека запрещает лишь запись
**на уровне владельца**, оставляя место и `DEBUG`, и отдельному счётчику
прогонов. Работа отдельная, идёт в урожай.
- **У пакета `internal/controller/worker` сегодня нет ни одного теста** → тест
на пустой прогон заводится этим изменением и приносит с собой первую тестовую
оснастку пакета: подменный журнал и чтение счётчика из реестра метрик. Оснастка
рискует разойтись с той, что уже есть в `internal/service` и
`internal/controller/http`; сверяется по ним, а не пишется с нуля.
## Migration Plan
Миграции нет: схема базы не трогается, данные не переносятся, формат файлов на
диске не меняется. Откат — обратный коммит.
## Open Questions
- **Переводить ли обе ошибки в sentinel** и убирать типы, которых никто не
читает. Довод конвенции остаётся в силе; цена — правка пяти мест против двух.
Решается отдельной работой, не этой.
- **Единая точка перевода доменной ошибки в HTTP-статус** — расхождение записано
в `docs/conventions/errors.md` и этим изменением не закрывается.
@@ -0,0 +1,48 @@
## Why
Воркер отличает «работы сейчас нет» от настоящего отказа хрупким способом: он
смотрит на точный тип значения ошибки. Пока никто по дороге не добавил к ошибке
пояснения, это работает. Первое же пояснение, добавленное в любом месте пути,
сделает пустой прогон неотличимым от поломки — молча, без единого признака в
коде. Сервис начнёт раз в секунду на каждый из трёх воркеров писать в журнал
отказ, которого не было, и засчитывать несуществующие сбои в счётчик работы.
Инвариант проекта «пустой прогон — не ошибка» стоит ровно на этой проверке.
Сегодня же на этих местах красен линтер, и вместе с двумя потерянными отказами
закрытия соединения он держит гейт проекта красным целиком.
## What Changes
- Признак «работы нет» и признак «подходящей задачи не нашлось» перестают
зависеть от точной формы значения ошибки: они узнаются по смыслу и переживают
любые пояснения, добавленные по дороге.
- Отказ при закрытии соединения с распознавателем перестаёт теряться: он либо
доходит до вызывающего, либо попадает в журнал владельца.
- Поведение снаружи не меняется: пользователь, внешняя программа и набор
состояний задачи остаются прежними.
- Гейт проекта становится зелёным целиком — снимается объявленный долг из
четырёх замечаний линтера.
## Capabilities
### New Capabilities
- `pipeline`: конвейер расшифровки — как задача переходит между состояниями, что
делает воркер, когда работы нет, и что считается отказом шага. Имя предвосхищено
маркерами долга в `docs/architecture.md`; этим изменением заводится **только**
требование о пустом прогоне, остальное поведение конвейера дописывает задача,
которая его тронет.
### Modified Capabilities
Нет. Требования `intake` изменение не трогает.
## Impact
- `internal/contract` — форма признаков «задачи нет» и «задача не найдена»;
- `internal/controller/worker` — проверка пустого прогона, журнал и счётчик
работы; у пакета сегодня нет ни одного теста, изменение заводит первый;
- `internal/service` — перевод «задача не найдена» в «работы нет»;
- `internal/adapter/repo/sqlite` — рождение признака «задача не найдена»;
- `internal/adapter/recognizer/yandex` и `main.go` — отказ закрытия соединения;
- гейт проекта и запись долга в `docs/conventions/errors.md` и `CLAUDE.md`.
@@ -0,0 +1,477 @@
# Триаж ревью: errors-as-instead-of-typecast
## Сводка
- **Размер / сложность / метка:** среднее × знакомое → **medium**. Режим — по графу.
База диффа `origin/master` непригодна (удалённая ветка отстала на десятки
коммитов и даёт 9136 строк шума); реальный вход — рабочее дерево: 5 файлов кода
и конфига, 3 документа, 2 новых теста, каталог `openspec/changes/`.
- **Состояние гейта:** зелёный. Проверено проходом `autotests` дважды (exit 0) и
переспрошено триажем поимённо: `golangci-lint run``0 issues`,
`go test ./...` → все пакеты `ok`. Объявленных долгов у гейта после этого
изменения нет — заявление `CLAUDE.md` подтверждено выводом инструментов, а не
декларацией.
- **Находок на входе:** 12 сырых от четырёх проходов → **9 различных причин**
после дедупликации (`Close`/`err2` пришёл трижды, MUST «ровно один раз» —
дважды) → **5 в первых двух секциях**, 3 в гипотезах, 1 отсеяна.
- **Потолки не срабатывали:** 2 + 3 = 5 при допустимых 3 + 4. Ничего не срезано,
ничего не выброшено молча.
### Сигнал о заниженной метке
**Пришёл, от одного прохода — `review-code`.** Основания: четыре узла правки,
изменение политики линтера на весь проект (`errcheck.check-blank`), переписанный
раздел «Гейт» в `CLAUDE.md`, введение новой capability `pipeline`. С меткой
`large` запускались бы отдельные проходы `architecture` и `operations` вместо
разбора этих тем внутри `basics`.
`review-basics` запускался и сигнала о метке не подал. Согласие/несогласие
проходов приоритет меняет, `confidence` — нет: метку выбирал `review-scope`.
**Ретроспективно сигнал подтверждается исходом:** три из пяти оставшихся находок
— про документы и норму (`architecture.md`, `conventions/README.md`,
`docs/review.md`, delta-спека), то есть ровно про тот слой, который на метке
`medium` разбирается наименее глубоко.
### План разметки с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| requirements | дельта `specs/pipeline/spec.md` | разбор | `specs` | **закрыта**, 2 находки (1 дошла до блокирующей) |
| autotests | `CLAUDE.md` «Гейт» | — | `autotests` | **закрыта**, 1 находка (понижена в гипотезы) |
| conventions | `docs/conventions/` (errors.md, logging.md, README.md) | разбор | `code` | **закрыта**, 4 находки (1 блокирующая, 1 отсеяна) |
| architecture | `docs/architecture.md` + `passport.md` | разбор | `basics` | **закрыта**, 1 находка |
| security | `docs/security.md` | разбор | `basics` | **закрыта**, 0 находок (все три вопроса неприменимы) |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | разбор | `basics` | **закрыта**, 1 находка (понижена: изменением не создана) |
Своих тем проекта нет. **Тем без отчёта нет** — все шесть вернули вывод.
---
## Блокирует мердж
### 1. Дельта-спека закрепляет контрактом поведение, которого у системы нет, и после архивации станет ложной посылкой для следующей задачи
- Файл: `openspec/changes/errors-as-instead-of-typecast/specs/pipeline/spec.md:36-40`
- Severity: major (`critical` не ставится: построенного пути к отказу или потере
данных нет — вред отложенный и через следующего автора)
- Confidence: high
- Найдено проходами: `specs` (находка 1), `basics` («дешевле переделать», п. 1) —
две независимые формулировки одной причины; оракула у них не было, оракул добыт
триажем.
- **Оракул (мой прогон, не на слово):**
```
go test -overlay=<scratchpad>/overlay.json -run TestTriageOneFailureOneRecord -v ./internal/service/
```
Тест поднимает `findJob` с репозиторием, отдающим `errors.New("database is gone")`,
и логирует возвращённое так, как это делает `worker.go:59`. Вывод:
```
level=ERROR msg="Failed to find and acquire job" state=created error="database is gone"
level=ERROR msg="Worker error" worker=probe error="failed find and acquire job: created, database is gone"
--- FAIL: один отказ дал 2 записей уровня ERROR, требование спеки — ровно одна
```
Второй оракул — дословный критерий приёмки этого же изменения
(`tasks.md`, «Добавлено разбором дизайна»): «**Норма не закрепляет контрактом
то, что конвенции помечают строкой «Расхождение»: место записи и уровень
журнала остаются долгом `docs/conventions/logging.md`**». Спека нормирует
именно место записи. Долг записан в `docs/conventions/logging.md:159-162`.
- Последствие: требование `MUST быть записан ровно один раз единственной
логирующей точкой` не имеет ни сценария (проверить его нечем — покраснеть оно
не может), ни срока, ни владельца, ни маркера долга. Оговорка «сегодняшнее
расхождение этим требованием нормой не объявляется» снимает обратное прочтение,
но не снимает сам MUST. После `archive` абзац переезжает в
`openspec/specs/pipeline/spec.md` и читается следующим автором как
действующая норма: он либо «починит» вторую точку записи, не приняв
сознательно решение об уровне (`ERROR` против `WARN` — дизайн его намеренно
не принимал, `logging.md` требует `WARN` для повторяющегося сбоя фонового
цикла), либо норма сгниёт. По `CLAUDE.md` («что такое сделана»: гейт зелёный
**и критерии приёмки проверены поимённо») изменение сейчас не выполнено по
своему собственному критерию.
- Предложение: см. варианты в развилке.
- **Действие: развилка.**
> В дельта-спеке `pipeline` абзац «Отказ шага MUST быть записан ровно один раз
> единственной логирующей точкой» нормирует поведение, которого у системы нет
> (оракул: один отказ хранилища даёт две записи ERROR), и нарушает
> собственный критерий приёмки задачи. Как поступаем?
>
> **(а) Ослабить норму до проверяемого сегодня.** Оставить в требовании только
> «отказ шага MUST быть виден владельцу записью в журнале и засчитан в счётчик
> с пометкой отказа» — это покрыто сценарием «Шаг отказал» и проверкой
> `TestFailureIsLoggedAndCounted`. Единственность логирующей точки вынести
> отдельной строкой долга со ссылкой на `docs/conventions/logging.md:159-162` и
> завести задачу в `tasks/items/`. Цена: правка дельта-спеки → **одобрение
> дизайна отменяется, нужен возврат на чекпоинт**; кода не касается.
>
> **(б) Убрать вторую точку записи в этом же изменении.** Снять
> `s.logger.Error("Failed to find and acquire job", …)` из
> `internal/service/transcribe.go:398`, оставив запись воркеру. Цена: это чужой
> долг и рост scope; требует сознательного решения об уровне записи, которое
> дизайн не принимал; нужен новый сценарий и новая проверка на «ровно один
> раз». Дельта-спека при этом всё равно правится (добавляется сценарий).
>
> **(в) Оставить как есть.** Цена: в актуальную спеку уезжает MUST, который
> система нарушает с момента `apply` и который никогда не покраснеет.
**Прямой ответ на заданный вопрос: да, принятие этой находки меняет
дельта-спеку — в любом из вариантов (а) и (б).** По правилу скилла `resolve`
это отменяет одобрение дизайна и требует возврата на чекпоинт к человеку.
Вариант (в) чекпоинта не требует, но оставляет дефект.
### 2. Закрытый долг остался записан в трёх местах, и одно из них — раздел, которым триаж отсеивает ложноположительные: следующая настоящая находка того же класса будет молча выброшена
- Файлы: `docs/conventions/README.md:21`, `docs/conventions/README.md:54`,
`docs/review.md:69-74`
- Severity: major
- Confidence: high
- Найдено проходом: `code` (находка B); третье место (`docs/review.md`) добавлено
триажем — это тот же дефект в третьем доме.
- **Оракул — дословные строки, живые на момент триажа:**
- `docs/conventions/README.md:21`: «…лог пишется на каждом шаге и дублируется
воркером, **доменные ошибки проверяются приведением типа**.» — расхождения
больше нет, изменение его закрыло.
- Правило самого же README (строки 27-29): «Каждое такое место названо в своей
записи строкой «*Расхождение:*». … **Проходу ревью строка «Расхождение»
говорит, что находка на этом месте уже известна и новой не считается.**»
- `docs/review.md:69-74`, раздел «Типовые ложноположительные»: «Настоящий
дефект рядом другой — **проверка идёт приведением типа и сломается при первой
же обёртке; он уже записан в
[conventions/errors.md](conventions/errors.md)**» — ссылка ведёт в файл, из
которого этот текст изменением удалён (`git diff docs/conventions/errors.md`,
строки 51-57 сняты).
- `docs/conventions/README.md:54`: «Непроверенное возвращаемое значение ошибки |
`.golangci.yml` → `errcheck` (кроме `defer Close` и `send`)» — таблица не
знает о включённом `check-blank`, то есть о том, что `_ = x.Close()` теперь
краснеет.
- Задача сама называет ровно две правки (`tasks.md`, 4.2): «В
`docs/conventions/errors.md` снять …; **прочие расхождения того файла** не
трогать». Про соседний `README.md` и про `docs/review.md` там нет ничего —
места просто пропущены, а не оставлены сознательно.
- Последствие: класс «молчание». `docs/review.md`, «Типовые ложноположительные»
— единственный проектный вход в шаг отсева триажа. Пока строка жива, следующий
прогон ревью, увидев приведение типа в новом коде, обязан отнести находку к
известным и выбросить её — то есть регрессия ровно того дефекта, который
чинило это изменение, пройдёт молча. `docs.py check` этого не ловит: ссылки не
битые, битым стало утверждение внутри документа.
- Предложение: снять фразу «доменные ошибки проверяются приведением типа» из
`README.md:21`; снять или переписать первый пункт «Типовых ложноположительных»
в `docs/review.md` — оговорка про настоящий дефект рядом больше неверна, сам
же пункт про `NoopJobError` остаётся верным; в строке таблицы «Механизировано»
заменить «(кроме `defer Close` и `send`)» на актуальный перечень
`exclude-functions` и упомянуть `check-blank: true`.
- **Действие: инлайн.** Три текстовые правки, решение однозначно, инвариантов не
трогает.
---
## Стоит исправить сейчас
### 3. `Close()` адаптера SpeechKit теряет отказ закрытия второго соединения — ровно тот дефект, который изменение объявило закрытым своим критерием приёмки
- Файл: `internal/adapter/recognizer/yandex/speechkit.go:79-91`
- Severity: minor (ущерб мал — см. находку 4: на этом пути оба `Close` в
сегодняшней реализации возвращают `nil`; вес держится критерием приёмки, а не
последствием)
- Confidence: high
- Найдено проходами: `specs` (находка 2), `code` (находка D), `basics` (находка
E) — **три независимых попадания, оракула ни у одного нет**. Совпадение
повышает приоритет (значит, бросается в глаза), но не `confidence`: под всеми
проходами одна модель.
- Оракул — дословный критерий приёмки этого же изменения (`tasks.md`, раздел
«Критерии приёмки»): «**Отказ `Close` не теряется молча: он либо возвращается
вызывающему, либо попадает в лог.**» Код:
```go
if err1 != nil {
return err1
}
return err2 // err2 при ненулевом err1 теряется молча
```
`errcheck` этого не видит: значение присвоено переменной. То есть механизация,
ради которой изменение включило `check-blank`, здесь мимо.
- Последствие: при остановке процесса, если оба gRPC-соединения отказали в
закрытии, владелец увидит один отказ из двух и будет разбирать половину
картины. Вероятность низкая, стоимость правки — одна строка.
- Предложение: `return errors.Join(err1, err2)` — тот же приём, который изменение
уже применило в конструкторе восемью строками выше, и `nil` из него отбрасывается.
- **Действие: инлайн.**
### 4. Комментарии и `design.md` описывают поведение gRPC, которого нет: журнал отправит владельца разбирать недоступность Yandex по ошибке, которая может возникнуть только от нашего двойного закрытия
- Файлы: `internal/adapter/recognizer/yandex/speechkit.go:56-59`, `main.go:124-129`,
`openspec/changes/errors-as-instead-of-typecast/design.md:81-92`
- Severity: minor
- Confidence: high
- Найдено проходом: `code` (находка A). Оракул проверен триажем поимённо.
- **Оракул — исходники `google.golang.org/grpc@v1.74.2`:**
- `clientconn.go:145` — `func NewClient(...)`: конструктор ленив, соединения не
открывает (устанавливает его первый RPC либо явный `Connect()`);
- `clientconn.go:1142-1156` — `Close()` возвращает **только** `nil` либо
`ErrClientConnClosing`, и только при повторном закрытии (`if cc.conns == nil`);
- `clientconn.go:67` — `ErrClientConnClosing = status.Error(codes.Canceled,
"grpc: the client connection is closing")`: статическая константа.
- Последствие, по местам:
- `speechkit.go:56-59` — комментарий обещает «**уже открытое** соединение могло
не закрыться»; на деле второй операнд `errors.Join` на этом пути **всегда
`nil`**. Конструкция безвредна и защищает от смены реализации, но читатель
выведет из комментария неверную модель API;
- `main.go:124-129` — комментарий «недоступность внешнего сервиса разбирает он»
и уровень `ERROR` (по `logging.md` — класс «сбой БД, диска, недоступность
внешнего сервиса»). Запись достижима только двойным закрытием, то есть нашим
дефектом. Владелец, увидев её, пойдёт разбирать Yandex вместо своего кода;
- `design.md:81-92` — тот же неверный образ записан двумя утверждениями
(«соединение с распознаванием уже открыто»; ошибка «несёт состояние
соединения и адрес узла» — `ErrClientConnClosing` не несёт ни того, ни
другого) и после архивации поедет дальше как обоснование.
- Предложение: код не трогать. Привести к действительности три текста: в
`speechkit.go` — «`grpc.NewClient` ленив, закрытие здесь почти всегда `nil`;
`errors.Join` стоит на случай смены реализации»; в `main.go` — «единственный
достижимый отказ здесь — повторное закрытие, то есть дефект наш, а не
внешнего сервиса»; в `design.md` — снять оба неверных утверждения.
- **Действие: инлайн.** Правка `design.md` фактическая, а не решенческая:
решение («отказ закрытия — по месту, а не единым правилом») остаётся тем же,
меняется только неверное описание чужого API. Дельта-спеку не трогает,
чекпоинта не требует.
### 5. Преамбула `docs/architecture.md` описывает состояние после архивации: сегодня она утверждает существование спеки, до которой нет пути
- Файл: `docs/architecture.md:11-23`
- Severity: minor
- Confidence: high
- Найдено проходом: `basics` («дешевле переделать», п. 2)
- Оракул — состояние дерева на момент триажа: `ls openspec/specs/` даёт **только**
`intake`; в новой преамбуле у пункта `intake` ссылка есть
(`../openspec/specs/intake/spec.md`), у пункта `pipeline` ссылки **нет** — она
снята, потому что `docs.py check` краснел битой ссылкой. Задача предписала эту
правку сама (`tasks.md`, 4.4).
- Последствие: пока изменение не заархивировано, документ утверждает «Заведены
две capability», а найти вторую читателю негде — единственная форма, в которой
она существует, лежит в `openspec/changes/`. Если изменение уедет без
`archive` (а `archive` — отдельный шаг и отдельная команда), расхождение
останется постоянным, и поймать его нечем: гейт зелёный именно потому, что
ссылку сняли.
- Предложение: у пункта `pipeline` дописать оговорку о том, где спека лежит
сейчас и когда переедет — «дельта в
`openspec/changes/errors-as-instead-of-typecast/specs/pipeline/spec.md`,
переезжает в `openspec/specs/` при архивации задачи». Одно предложение, ссылка
на существующий файл, гейт остаётся зелёным.
- **Действие: инлайн.**
---
## Гипотезы без доказательства
### Новая ветка `errors.Join` в `newSpeechKitService` не покрыта ни одним тестом
Понижено с **major/high** до гипотезы и, по существу, до строки в границах
покрытия. Пришло от прохода `autotests` с настоящим оракулом
(`go tool cover -func` → `newSpeechKitService 0.0%`; триаж перепроверил:
`go test -coverprofile` даёт `internal/adapter/recognizer/yandex — coverage:
0.0% of statements`, тестовых файлов в пакете нет вовсе).
Почему понижено: находка 4 показывает, что ветка практически недостижима.
`grpc.NewClient` с константным корректным адресом (`operation.api.cloud.yandex.net:443`)
и валидными TLS-credentials отказывает только на разборе target'а, а второй
операнд `errors.Join` на этом пути всегда `nil`. То есть непокрыт код, который
и выполняться-то не будет. **Предложенное самим проходом «вынести создание
клиента за шов» триаж не рекомендует**: это разросшаяся абстракция в адаптере
ради единственной недостижимой ветки, и заказывать её на основании процента
покрытия — ровно та правка, от которой защищает потолок. Принят второй вариант
самого же прохода: занести в границы покрытия (сделано ниже).
### Признака живости воркера нет: сутки тишины одинаково означают «записей не слали» и «все три воркера висят»
Пришло от `basics` (находка F), понижено: **изменением не создано**. Спека
нормирует молчание пустого прогона, но само молчание стоит на инварианте
`CLAUDE.md` «`NoopJobError` — не ошибка», который старше этой задачи.
Оракула на «воркер висит» нет — поднять SpeechKit в тесте нечем (см. «Недоступно
проверке»). Более того, свойство **уже заведено задачей**:
`tasks/items/service-observability.md`, критерий завершения 1 — «По метрикам
видно, что конвейер встал: задача висит в состоянии дольше обычного, и это
отличимо от «работы нет»». Новой находкой не считается; чинить в этом изменении
нечего, иначе это рост scope на целую тему наблюдаемости.
### Норма спеки пересказана прозой в `docs/conventions/errors.md` — второй дом факта
Пришло от `basics` («дешевле переделать», п. 3). Оракула нет: `openspec/config.yaml:36`
действительно предупреждает, что «второй дом факта расходится с первым молча»,
но новый абзац `errors.md:51-57` заканчивается словами «**Норма записана
требованием capability `pipeline`**», то есть первый дом назван явно. Разделение
здесь защитимо: спека говорит, что делает система, конвенция — как это пишут в
коде. Доказательства предстоящего расхождения у меня нет, а превентивная правка
свелась бы к спору о вкусе. Оставлено гипотезой; если расхождение когда-нибудь
случится, эта запись — след.
---
## Promote candidates
- **Форма `msg` в журнале.** `logger.Error("failed to close audio recognizer", …)`
(`main.go:126`) — предложение, а не короткая категория, вопреки
`docs/conventions/logging.md:32-37`. Пришло от `code` (находка C) и **отсеяно
как находка**: `logging.md:44` уже несёт строку «*Расхождение:* сегодня `msg` —
предложение вида `Starting conversion job`», и весь корпус журнала написан в
этой форме; правка одной строки сделает журнал неоднороднее, а не однороднее.
Это претензия на правило, а не на этот код: либо сканер формы `msg`, либо
отдельная задача на разовую миграцию всего корпуса.
- **Устаревание утверждения внутри документа не механизировано.** `docs.py check`
ловит битые ссылки и раскладку, но не ловит ситуацию находки 2: файл на месте,
ссылка цела, неверным стало утверждение о его содержимом. Кандидат:
проверка «строка «*Расхождение:*» и упоминание расхождения в чужом файле
живут парой» либо явные якоря вместо ссылок на файл целиком.
- **Покрытие изменённых строк не считается ничем** (`CLAUDE.md`, «Гейт», сказано
прямо). Именно поэтому проход `autotests` вынужден был звать `go tool cover`
руками, а решение «покрывать или занести в границы» принималось на глаз.
Кандидат в шаг гейта, а не в находку ревью.
---
## Границы покрытия
### План: темы, их дома и глубины
Все шесть тем плана (`requirements`, `autotests`, `conventions`, `architecture`,
`security`, `operations`) имели дом и вернули отчёт — см. таблицу в сводке. Тем
без дома в плане нет. Своих тем проекта нет.
### Какие проходы запускались и в каком режиме
Метка `medium`, режим «по графу». Запущены: `review-autotests`, `review-specs`,
`review-code`, `review-basics`, `review-triage`. Состав соответствует плану
`review-scope`.
### Какие проходы не запускались и почему
- Отдельные проходы **`architecture` и `operations`** — по метке: на `medium`
эти темы разбираются внутри `review-basics` меньшей глубиной. `review-code`
подал сигнал, что метка, вероятно, занижена (см. сводку); при `large` эти два
прохода шли бы отдельно и глубже.
- Прохода **идиоматичности** в конвейере нет — упразднён.
- Прохода **независимой реализации** в конвейере нет — снят по стоимости.
### Что каждый запущенный проход не мог проверить в принципе
- `autotests` — судит оракулы и их способность краснеть, но не судит, верна ли
сама норма, которую они проверяют; поведение под реальным потоком не
воспроизводит.
- `specs` — судит соответствие нормы и кода, но не судит, нужна ли норма и не
дорога ли она; альтернативной формулировки требования не строит.
- `code` — читает дифф; поведения системы целиком, в сборе и под нагрузкой, не
наблюдает.
- `basics` — тремя темами на малой глубине; ни одну из них до дна не доводит по
построению.
- `triage` (я) — **ничего нового не нахожу по определению**: я не читаю код в
поисках дефектов, я работаю с чужими выводами. Пропуск любого прохода — мой
пропуск тоже, и единственное, что я могу с этим сделать, — назвать его
поимённо, что и сделано выше.
### Что осталось целиком на человеке
**Не проверит ни один проход** (`docs/review.md:159-166`):
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно** (`docs/review.md:168-175`):
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Плюс этим прогоном:**
- `internal/adapter/recognizer/yandex` — покрытие **0.0 %**, тестовых файлов в
пакете нет вовсе; новая ветка `errors.Join` в `newSpeechKitService:53-63` не
исполняется ни одной проверкой. Заносится сюда сознательно вместо заведения
шва (см. гипотезы);
- `main.go` — пакет `main` в проекте никогда не тестировался, шва нет; новый
`defer` с логированием отказа закрытия проверен только чтением (`tasks.md`,
2.4: между постановкой `defer` и завершением `main` нет `os.Exit`);
- реальный путь `NoopJobError` от SQLite-репозитория (не от подставного) —
проверки обоих звеньев работают на заглушках.
**Общее, что не проверяет никто:** история инцидентов; поведение под реальным
потоком; поведение внешних систем в их версиях (утверждения о gRPC в находке 4
сняты с исходников `v1.74.2` — на другой версии их надо перепроверять);
завязка потребителей на текущее поведение; вопрос «а нужна ли эта
функциональность вообще».
### Каких документов проекта не хватило
- **`docs/adr/` по теме этого изменения — записи нет.** Решение «`NoopJobError`
и `JobNotFoundError` остаются типизированными, а не становятся sentinel»
принято в `design.md` (Решение 1) и живёт только там, в документе изменения,
который после архивации уедет в `openspec/changes/archive/`. Через полгода
обоснование придётся выводить заново.
- **Строка «*Расхождение:*» не имеет владельца и срока по построению**
(`docs/conventions/README.md:27-29`). Из-за этого долг «одна запись отказа —
две строки журнала» нельзя ни просрочить, ни закрыть — что и породило находку 1.
- Прочих пробелов проходы не заявили. `docs/security.md`, `docs/database.md`,
`docs/passport.md`, `docs/conventions/*` на месте и периметр называют;
инварианты в `CLAUDE.md` есть и снабжены severity — деградации по этому
разряду на этом прогоне не было.
### Сработавшие потолки — по строке на проход
- `review-basics` — потолок **2/4**, показано 2 находки + 3 пункта «дешевле
переделать до мерджа». Потолок не срабатывал, за срезом ничего не осталось.
Проход сообщил это сам.
- `review-autotests` — **о своём потолке не сообщил**; показана 1 находка и 1
отклонённая гипотеза. Это находка о прогоне: сколько осталось за срезом,
установить нечем.
- `review-specs` — **о своём потолке не сообщил**; показано 2 находки. То же.
- `review-code` — **о своём потолке не сообщил**; показано 4 находки при
раздельных потолках половин `conventions` и `техника`. То же.
- `review-triage` (я) — потолки 3 и 4, занято 2 и 3. **Потолок не срабатывал,
ничего не срезано, ничего не выброшено молча.** Единственная отсеянная находка
(форма `msg`) названа поимённо в `Promote candidates` с причиной отсева.
### Четыре строки триажа: чего в конвейере нет вовсе
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
его не открывает. Изменение вводит новую capability `pipeline` и меняет
политику линтера на весь проект; расходится ли это с записанными ADR, ни один
проход не проверял. Ловит такое сверка документации — скилл
`av-dev-docs:healthcheck`, и звать его надо руками (`CLAUDE.md`, «Гейт»:
«согласованность документов между собой и с кодом … звать его надо руками»).
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
процессный. Каждое число в этом отчёте снято на этом прогоне приложенной
командой: `0 issues` от `golangci-lint run`, `0.0 % of statements` от
`go test -coverprofile`, «2 записи ERROR» от прогона с `-overlay`. Чисел без
команды замера в отчёте нет.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
проходом.** `errors.Join` в конструкторе, `errors.As` с указателем на
указатель, `defer` с телом вместо голого вызова — все три конструкции судились
по внутренним конвенциям проекта и по линтеру. Различение «идиоматично против
просто распространено» не спрашивал никто с тех пор, как упразднён проход про
идиоматичность.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру. Вопрос
«а не решается ли задача «признак не ломается обёрткой» иначе — например,
sentinel-значениями, как сам `design.md` рассматривает в Решении 1» никем
независимо не проверялся: рассмотрел и отверг его автор дизайна, и ревью
сверялось с его же рассуждением.
Метка `medium`, поэтому пятая строка про `small` не применяется: темы `security`,
`operations` и `architecture` разбирались проходом `basics` по своим домам, а не
только по инвариантам `CLAUDE.md`.
---
**Формулировка «критичных проблем не обнаружено» в этом отчёте не употребляется
и не подразумевается.** `critical` не выставлен ни одной находке по конкретной
причине: построенного пути к потере данных, порче или утечке секрета ни один
проход не предъявил, а `critical` без оракула или построенного пути не
существует. Что именно осталось непроверенным — перечислено выше поимённо.
@@ -0,0 +1,76 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага.
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
пакета `internal/controller/worker` и перевод признака в `internal/service`.
Сознательно не описаны переходы состояний и цепочка `created → converted →
transcribe → done | failed`, захват задачи и срок его протухания, отмена
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три
записи отказа в секунду и столько же засчитанных сбоев, которых не было.
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
в цепочке своей ошибки. Узнавание по смыслу видит признак на любой глубине, и
отказ, к которому признак примешался, воркер зачёл бы пустым прогоном: задача
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
быть засчитан в счётчик работы с пометкой отказа.
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
конвейера и следом воркер, — а уровень стоит `ERROR` там, где конвенция просит
`WARN` для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
`docs/conventions/logging.md`, раздел «Ошибки», строкой «Расхождение, и оно
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
дописывает норму сюда.
#### Scenario: Работы в состоянии нет
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
- **WHEN** воркер делает свой прогон
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
- **AND** счётчик работы воркера не растёт
#### Scenario: Признак пустого прогона дошёл с пояснением
- **GIVEN** работы в опрашиваемом состоянии нет
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
- **WHEN** воркер делает свой прогон
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
уровне владельца нет
#### Scenario: Шаг отказал
- **GIVEN** шаг конвейера вернул отказ
- **WHEN** воркер завершает прогон
- **THEN** отказ виден владельцу сервиса записью в журнале
- **AND** счётчик работы воркера растёт с пометкой отказа
#### Scenario: Шаг сделал работу
- **GIVEN** шаг конвейера отработал задачу без отказа
- **WHEN** воркер завершает прогон
- **THEN** счётчик работы воркера растёт с пометкой успеха
- **AND** записи об отказе в журнале нет
@@ -0,0 +1,100 @@
## 1. Признак узнаётся по смыслу
- [x] 1.1 В `internal/controller/worker/worker.go:51` заменить приведение
`err.(*contract.NoopJobError)` на `errors.As` с целью типа
`*contract.NoopJobError`; порядок ветвей (счётчик, затем журнал) сохранить.
- [x] 1.2 В `internal/service/transcribe.go:392` заменить приведение
`err.(*contract.JobNotFoundError)` на `errors.As`.
- [x] 1.3 `golangci-lint run` не даёт замечаний `errorlint` — проверить прогоном.
## 2. Отказ закрытия не теряется
- [x] 2.1 В `.golangci.yml` включить `errcheck.check-blank: true`. Без этого
`_ = conn.Close()` снимает замечание, и оракул раздела 4 не различает годную
реализацию от негодной. Прогон обязан остаться на прежних 4 замечаниях — новых
мест правило не открывает (проверено при разборе дизайна).
- [x] 2.2 В `internal/adapter/recognizer/yandex/speechkit.go:55` собрать отказ
закрытия `sttConn` с отказом соединения через `errors.Join`; `nil` от закрытия
форму ошибки не меняет.
- [x] 2.3 В `main.go:124` заменить `defer recognizer.Close()` на `defer` с телом,
пишущим отказ закрытия в журнал.
- [x] 2.4 Проверить, что между постановкой этого `defer` и штатным завершением
`main` нет вызова `os.Exit`: иначе запись не появится (риск из `design.md`).
- [x] 2.5 `golangci-lint run` не даёт замечаний `errcheck` — проверить прогоном.
- [x] 2.6 Мутация оракула: временно заменить оба места на `_ = …Close()`
`golangci-lint run` обязан покраснеть обоими. Не покраснел — оракул критерия 4
не работает, и шаг 2.1 сделан неверно. Восстановить код после проверки.
## 3. Оракул на пустой прогон — оба звена пути
- [x] 3.1 Завести первый тест пакета `internal/controller/worker`; оснастку
(подменный журнал, чтение счётчика из реестра метрик) взять по образцу тестов
`internal/service` и `internal/controller/http`, а не писать заново.
- [x] 3.2 Тест: воркер, чья работа вернула `*contract.NoopJobError`, **обёрнутый**
`fmt.Errorf("…: %w", err)`, не пишет в журнал ни одной записи.
- [x] 3.3 Тот же тест: счётчик `transcriber_worker_job_count` для этого воркера
не изменился — значение читается до и после прогона.
- [x] 3.4 Тест отказа: обычный отказ шага даёт запись в журнал и рост счётчика с
пометкой отказа — иначе оракул зелен на коде, который не считает отказом ничего.
- [x] 3.5 Тест успеха: прогон без отказа растит счётчик с пометкой успеха и не
пишет об отказе. Без него реализация, снявшая счёт успешных прогонов, проходит
все проверки, а доля отказов перестаёт считаться.
- [x] 3.6 **Второе звено пути**: тест в `internal/service` на `findJob` с
подставным репозиторием, возвращающим `fmt.Errorf("…: %w",
&contract.JobNotFoundError{…})` — ожидание `*contract.NoopJobError`. Без него
правка 1.2 принимается только линтером, а линтер проверяет форму, не смысл.
- [x] 3.7 Мутация, поимённо по обеим строкам: вернуть приведение типа в
`worker.go` — краснеют 3.2 и 3.3; вернуть приведение (или подставить
несовпадающую цель `errors.As`) в `transcribe.go` — краснеет 3.6. Обе мутации
обязаны покраснеть по отдельности. Восстановить код после проверки.
## 4. Учёт
- [x] 4.1 `task gate` зелёный целиком; в `CLAUDE.md`, разделе «Гейт», снять
запись об известном отказе `golangci-lint` и о задаче
`errors-as-instead-of-typecast`.
- [x] 4.2 В `docs/conventions/errors.md` снять пометку «*Расхождение, и оно
опасно:*» о приведении типа и строку преамбулы «доменные ошибки проверяются
приведением типа»; прочие расхождения того файла не трогать.
- [x] 4.3 Дописать `## Purpose` в спеку `pipeline` — что это за capability и что
сознательно **не** описано (переходы состояний, захват, срок протухания,
отмена контекста, освобождение ресурсов). Без него следующий автор не отличит
«остальное не нормировано» от «остального не бывает»; валидатор этого не ловит.
- [x] 4.4 Поправить преамбулу `docs/architecture.md`: заведены две capability,
`intake` и `pipeline`, и в `pipeline` описан только пустой прогон воркера.
Маркеры `<!-- канон: поведение → openspec/specs/pipeline -->` на строках про
идемпотентность и цепочку состояний оставить долгом — но так, чтобы их нельзя
было прочесть как «уже переехало».
- [x] 4.5 `openspec validate --strict errors-as-instead-of-typecast` проходит.
## Критерии приёмки
Дословно из записи задачи `tasks/items/errors-as-instead-of-typecast.md`:
- Обе проверки идут через `errors.As` либо через `errors.Is` по sentinel.
Оракул — `golangci-lint run` не даёт замечаний `errorlint`.
- Обёртка `fmt.Errorf("…: %w", err)` в середине пути не ломает распознавание.
Оракул — тест: обёрнутый `NoopJobError` воркер по-прежнему считает пустым
прогоном и не пишет ни лога, ни метрики.
- Метрика `transcriber_worker_job_count` на пустом прогоне не растёт. Оракул —
тот же тест, проверка значения счётчика до и после.
- Отказ `Close` не теряется молча: он либо возвращается вызывающему, либо
попадает в лог. Оракул — `golangci-lint run` не даёт замечаний `errcheck`, и
`task gate` зелёный целиком.
Добавлено разбором дизайна (рубрика прохода `rubric`, потолок пунктов не
применялся):
- Каждый оракул способен покраснеть на негодной реализации. Проверяется
мутацией — шаги 2.6 и 3.7; критерий, чей единственный оракул молчание линтера,
годится только когда линтер краснеет на **всех** негодных реализациях.
- Признак пустого прогона распознаётся на **обоих** звеньях пути, и каждое звено
имеет свою падающую проверку.
- Классификация отказа не зависит от текста ошибки: ни подстроки `Error()`, ни
точной формы значения.
- Норма не закрепляет контрактом то, что конвенции помечают строкой
«Расхождение», и не объявляет обязательным недостижимое: число записей об
отказе и уровень журнала остаются долгом `docs/conventions/logging.md`, а
требование их не нормирует ни в ту, ни в другую сторону. Проверено ревью кода:
первая редакция требовала «ровно один раз», чему код не соответствовал с
первого дня.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,123 @@
## Context
Тесты приёма записи по HTTP лежат в репозитории с коммита `87d8b05` и не проходили
ни разу. Отказов четыре, и причина у них одна: тест поднимает приём вместе с
**настоящим** источником метаданных — внешней программой `ffprobe`, — а
скармливает ему либо файл, которого нет, либо строку «test audio content».
`ffprobe` такой вход отвергает, приём отвечает отказом, тест ждал успеха.
Отсюда следствие дороже самих тестов: красный `go test` в проекте объявлен долгом,
и настоящий отказ приёма от него неотличим.
Второе, что мешает: каталог хранения приём берёт из строки `data/files`,
относительной к рабочему каталогу процесса. Чтобы файлы не улетали в репозиторий,
каждый тест зовёт `os.Chdir` — то есть меняет состояние **всего процесса**.
Параллельно такие тесты гонять нельзя, а `errcheck` на непроверенных `os.Chdir`
молчит только потому, что весь `_test.go` вынесен в исключения линтера.
## Goals / Non-Goals
**Goals:**
- проверка приёма судит **наш** код, а не способность `ffprobe` разобрать вход;
- отказ чтения метаданных проверен отдельным случаем: сегодня эту ветку не
проверяет ничто;
- проверки проходят на чистом клоне без подготовки файлов руками и без
установленного `ffprobe`;
- проверки не трогают состояние процесса и не мешают друг другу.
**Non-Goals:**
- поведение приёма не меняется — ни коды ответов, ни имена полей, ни раскладка
файлов на диске. Задача про то, чем поведение проверяется;
- код `internal/service` и `internal/controller/http` не правится: подстановка
туда уже заведена, и пользоваться ей — вопрос теста, а не сервиса;
- проверка настоящего `ffprobe` на настоящей записи здесь не заводится — см.
«Risks / Trade-offs».
## Decisions
### Подставной источник метаданных вместо настоящего `ffprobe`
Приём берёт длительность через интерфейс `contract.AudioMetaViewer`, а
реализацию получает снаружи при сборке. Тест подставляет свою: она отдаёт
заданную длительность и, когда тесту нужен отказ, — заданную ошибку. Одним
типом закрываются оба случая, и ветка отказа впервые становится проверяемой.
Рассмотрено и отвергнуто:
- **положить настоящую запись в `testdata`.** Отвергнуто дважды: `.gitignore`
строкой `*.m4a` её не пустит, а `CLAUDE.md` прямо говорит, что `testdata` в
проекте нет и тесты создают нужное во временном каталоге. Снимать запрет ради
теста — менять правило проекта под удобство одного файла;
- **порождать запись `ffmpeg` прямо в тесте.** Отвергнуто: проверка приёма
начинает требовать установленных `ffmpeg` и `ffprobe`, а критерий приёмки
требует обратного — прогона с `ffprobe`, убранным из `PATH`;
- **проверять приём сквозь настоящий `ffprobe`.** Отвергнуто: это проверка
внешней программы, а не нашего кода. Она найдёт отказ `ffprobe` и не найдёт
ошибку в приёме — ровно наоборот тому, зачем эти тесты писались.
### Каталог хранения задаётся снаружи, а не рабочим каталогом процесса
Каталог тесту даёт `t.TempDir()`, и он же уезжает в сборку сервиса. `os.Chdir`
уходит целиком. Человек увидит разницу в двух местах: тесты можно гонять
параллельно, и упавший тест больше не оставляет процесс в чужом каталоге, ломая
следующие за ним.
Рассмотрено и отвергнуто: **оставить `os.Chdir`, но восстанавливать каталог
надёжнее.** Отвергнуто — надёжного способа нет: рабочий каталог у процесса один
на все горутины, и любой параллельный тест увидит чужой.
### Запрос собирается из байтов, а не из файла на диске
Сегодня вспомогательная функция открывает файл по пути, поэтому каждому случаю
нужен файл в текущем каталоге. Она начинает принимать имя и содержимое —
диск из подготовки запроса уходит, а имя файла (то, чем проверяется выбор
расширения) задаётся прямо, без создания одноимённого файла.
### Внешних программ в этом тесте не остаётся ни одной
Конвертер в сборке теста тоже настоящий, хотя проверки его не зовут: приём
конвертацию не делает. Он заменяется подставным вместе с источником метаданных.
Смысл не в экономии: после этого «тест не зависит от внешних программ»
проверяется взглядом на список импортов, а не рассуждением о том, какие ветки
кода отработают.
### Послабление линтера для тестов снимается
Исключение `errcheck` на `_test.go` в `.golangci.yml` заведено под непроверенные
`os.Chdir`. Их не остаётся — исключение снимается вместе с ними. Держать
послабление после того, как ушла его причина, значит оставить весь будущий тестовый
код без проверки возвращаемых ошибок и не помнить почему.
## Risks / Trade-offs
- **Настоящий `ffprobe` теперь не проверяется ничем.** До этой правки его звал
тест приёма — правда, звал так, что тот всегда отказывал, то есть проверял
отказ и выдавал его за успех. Формально покрытие не теряется: проверялось и
раньше ничего. Но дыра называется прямо — у `internal/adapter/metaviewer/ffmpeg`
своего теста нет, и разбор вывода `ffprobe` не проверен. → Смягчение: находка
уходит в урожай отдельной задачей; здесь она не чинится, потому что требует
записи в репозитории либо отдельного вида проверок, а это своё решение.
- **Снятое послабление линтера действует на весь будущий тестовый код.** →
Смягчение: это и есть цель. Если оно окажется тяжёлым, его вернут осознанно и
с причиной в самом файле, а не по наследству.
- **Норма про имя отправителя накрывает хранилище, но не журнал.** Ревью дизайна
нашло, что приём пишет имя файла пользователя в журнал — это нарушение
инварианта «содержимое записи остаётся приватным», объявленного критическим и
необратимым, и идёт оно по общему пути, то есть и для записей из Telegram.
Решением человека на чекпоинте правка вынесена отдельной задачей: она про
поведение сервиса, а не про проверки. → Смягчение: находка уходит в урожай.
Спека при этом нормирует только хранилище и о журнале молчит намеренно —
писать норму, которой код заведомо не следует, значит завести спеку, которая
врёт с первого дня.
- **Отказ чтения метаданных оставляет файл на диске.** Соседняя ветка отказа
(не удалось завести учётную запись файла) файл убирает, эта — нет, как и
отказ записи на диск. Ни задачи, ни записи в учёте под такой файл нет, и
сопоставить его не с чем. Решением человека на чекпоинте вынесено отдельной
задачей: правка трогает три ветки отказа и меняет поведение на диске. →
Смягчение: находка уходит в урожай; сценарий отказа в спеке нормирует только
то, что проверяется здесь, — код ответа и незаведённую задачу.
- **Спека `intake` описывает только приём по HTTP.** Приём из Telegram остаётся
в коде и без требований. → Смягчение: назван в самой спеке. Требование,
написанное без проверки, было бы предположением, а не нормой.
@@ -0,0 +1,46 @@
## Why
Тесты приёма записи по HTTP заведены давно и не проходили ни разу: один просит
файл, которого в репозитории нет и по правилам быть не может, остальные скармливают
строку «test audio content» настоящему `ffprobe` и ждут успеха. Из-за этого красный
`go test` в проекте перестал что-либо значить — настоящий отказ приёма неотличим от
привычного шума, и об ошибке в приёме записи мы узнаем не от машины, а от
пользователя.
## What Changes
- Проверка приёма перестаёт зависеть от внешнего `ffprobe` и от записи, лежащей
в репозитории: длительность в тестах даёт подставной источник метаданных.
Проверяем свою логику приёма, а не чужую способность разобрать файл.
- Появляется проверка отказа: источник метаданных не смог прочитать запись —
приём отвечает отказом, а не успехом. Сегодня это поведение не проверено ничем.
- Тесты перестают менять рабочий каталог всего процесса: каталог хранения
задаётся приёму снаружи, каждому случаю свой временный.
- Поведение приёма записывается требованиями: сегодня оно живёт только в коде,
и спорить о том, что здесь правильно, не с чем.
Поведение самого приёма при этом не меняется — меняется только то, чем оно
проверяется. Задача про проверки.
## Capabilities
### New Capabilities
- `intake`: приём записи от внешней программы по HTTP и опрос готовности —
что считается принятой записью, что возвращается в ответ и что происходит,
когда запись не удалось прочитать. Приём из Telegram эта спека пока не
описывает: его не трогает ни одна проверка этой задачи, а требование,
написанное без проверки, — предположение.
### Modified Capabilities
Нет: спек в проекте ещё не заведено.
## Impact
- `internal/controller/http/transcribe_test.go` — переписывается целиком;
- сборка сервиса расшифровки в тестах: каталог хранения и источник метаданных
подставляются, а не берутся из окружения;
- `.golangci.yml` — послабление `errcheck` для тестов снимается, если после
правок в тестах не остаётся непроверенных вызовов;
- внешних границ, схемы базы, формата файлов на диске и контракта HTTP API
изменение не касается.
@@ -0,0 +1,390 @@
# Триаж ревью: `fix-http-handler-tests`
## Сводка
- **Размер:** малое. **Сложность:** знакомое. **Метка:** `small`.
Обоснование разметки (`review-scope`): изменение трогает один тестовый файл и
одну строку конфига линтера, поведение сервиса не меняется, отрицательный тест
метки (миграция, формат файла на диске, публичный контракт API, имя ключа) не
срабатывает — контракт HTTP API спекой **фиксируется**, а не меняется.
- **Режим прогона:** по графу. Дизайн — одна стадия (`specs`), код — `autotests`
`specs` + `code` → триаж.
- **Сигнал о заниженной метке:** пришёл от `review-code` — возражений нет, метку
`small` проход счёл обоснованной. `review-basics` не запускался (своих тем у
проекта нет), второго независимого подтверждения метки нет.
- **Состояние гейта (проверено мной на этом прогоне):**
- `go test ./...`**зелёный** (`ok internal/controller/http 0.013s`); это и
есть цель изменения;
- `golangci-lint run`**4 замечания, ровно объявленный долг**
(`speechkit.go:55`, `main.go:124`, `worker.go:51`, `service/transcribe.go:394`).
Ни одного нового, в том числе после снятия исключения `errcheck` для `_test.go`;
- критерий приёмки «не зависит от `ffprobe`» подтверждён: собранный
`go test -c` бинарь проходит под `env -i PATH=<пустой каталог>`;
- критерий «`os.Chdir` не остаётся» подтверждён: `grep -rn 'os.Chdir' internal/` пуст.
- Итог: гейт красный **только унаследованным долгом**; новых красных шагов нет.
### План разметки с исходом по каждой теме
| тема | дом | глубина | закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/changes/fix-http-handler-tests/specs/intake/spec.md` | сверка | `specs` | **закрыта**, 3 находки (потолок 3/3 сработал), 2 из них починены и мной перепроверены мутацией |
| autotests | `CLAUDE.md` § Гейт | — | `autotests` | **закрыта**, 0 находок; отчёт о гейте + 1 строка в границы покрытия. О своём потолке проход не сообщил |
| conventions | `docs/conventions/README.md` (на `small` — только README) | сверка | `code` | **закрыта**, 1 находка (потолок 1/2), **не починена** — единственный блокер ниже |
| architecture | `CLAUDE.md` § Инварианты | сверка | `code` | **закрыта**, 0 находок (потолок инвариантов 0/1) |
| security | `CLAUDE.md` § Инварианты | сверка | `code` | **закрыта**, 0 находок — но см. предупреждение ниже |
| operations | `CLAUDE.md` § Инварианты | сверка | `code` | **закрыта**, 0 находок |
Тем без отчёта нет. Тем без дома нет.
**Предупреждение по теме `security`.** «0 находок» здесь не значит «чисто».
Нарушение `critical`-инварианта «содержимое записи остаётся приватным»
(`internal/service/transcribe.go:107` пишет `"file_name", fileName` — имя файла
пользователя — в журнал) найдено **на стадии дизайна** и **сознательно отложено
решением человека на чекпоинте**, поэтому проход кода вернул пустой итог: находка
уже известна и вынесена. Она в урожае, не в блокерах, и это решение человека, а не
моё. Тот же путь проходят записи из Telegram.
### Арифметика
- **На входе:** 14 позиций — 7 именованных находок (`specs` 3, `code` 4),
3 названные ниже потолка, 3 отложенные решением человека, 1 замечание о
непокрытом конвейере от `autotests`.
- **После дедупликации, добычи оракулов и отсева:** **2** позиции, требующие
действия (1 блокер + 1 развилка). Остальное — в урожай, гипотезы и promote,
ничего не выброшено молча.
- Починенное проверено мной независимо: обе `major`-находки `specs` закрыты,
мутации их роняют (оракулы ниже).
---
## Блокирует мердж
### `CLAUDE.md` продолжает объявлять долгом отказ, которого больше нет, — и следующий настоящий отказ тестов приёма спишут на него молча
- Файл: `CLAUDE.md:96-101`; сопутствующее: `tasks/BACKLOG.md:23`,
`tasks/items/http-handler-tests-never-green.md`
- Severity: major
- Confidence: high
- Действие: **инлайн**
- Оракул (мой, на этом прогоне):
- дословно `CLAUDE.md:98-101`: «`go test ./...` падает в
`internal/controller/http`: тесты требуют `testdata/sample.m4a`, которого в
репозитории нет и не было… Заведено задачей `http-handler-tests-never-green`»;
- `go test ./...``ok git.vakhrushev.me/av/transcriber/internal/controller/http 0.013s`;
- дословно `CLAUDE.md` § Работа: «Два объявленных долга из раздела „Гейт“
сломанным состоянием **не** считаются, пока их не закрыли задачами».
- Последствие: после мерджа проект будет письменно утверждать, что красный
`go test` в `internal/controller/http` — это норма. Ровно этот механизм записан в
журнале дефектов (`docs/review.md`, запись 2026-08-10): «тест, который никогда
не проходил, обнуляет сигнал всего пакета: настоящий отказ в нём становится
неотличим от привычного шума». Изменение восстанавливает сигнал в коде и
оставляет его выключенным в документе, по которому судят «сломано ли». Отказ
будет молчаливым: никто не станет разбираться в отказе, объявленном известным.
- Предложение: убрать первый из двух известных отказов в `CLAUDE.md` § Гейт
(остаётся только `golangci-lint`), закрыть задачу штатным путём каталога
(`tasks.py close` — реализованные в `REJECTED.md` не идут, у них есть коммит),
снять строку из `tasks/BACKLOG.md`. Задача `tasks.md` этого шага не содержит —
добавить его в чек-лист.
- Найдено проходом: `review-code`/конвенции; оракул и провенанс — триаж.
- Почему блокер, а не «стоит исправить»: `CLAUDE.md` § Работа — единственное
место, где записано, что считается сломанным. Пока оно врёт, определение
сделанного у следующей задачи опирается на неверный список. Правка
механическая, путь документирован, цена — минуты.
**Замечание о разделении обязанностей.** Общая согласованность документов между
собой и с кодом — работа скилла `av-dev-docs:healthcheck`, а не ревью
(`CLAUDE.md` § Гейт говорит это прямо). Здесь исключение узкое и обосновано: речь
не о дрейфе документации вообще, а о том, что **это самое изменение** закрывает
долг, поимённо перечисленный в `CLAUDE.md`, и без правки определение «сломано»
становится ложным в момент мерджа.
---
## Стоит исправить сейчас
### Переименование маршрута `POST /api/audio` в `main.go` уедет зелёным: тест ходит по своей копии регистрации
- Файл: `main.go:198-202` против `internal/controller/http/transcribe_test.go:140-144`
- Severity: minor
- Confidence: high
- Действие: **развилка**
- Оракул (мой, мутация в копии дерева, `/tmp/.../scratchpad/mut`):
`api.POST("/audio", …)``api.POST("/upload", …)` в `main.go`;
`go build ./...` проходит, `go test ./internal/controller/http/ -count=1`
`ok … 0.017s`. Тест зелёный при сломанном контракте.
Для сравнения — то, что теперь ловится: переименование тега
`json:"job_id"``json:"jobId"` роняет `TestCreateTranscribeJob_Success`
(«does not contain "job_id"»), удаление `s.jobRepo.Create(job)` роняет его же.
- Последствие: имена полей ответа изменение защитило (это и была починенная
`major`-находка), а путь маршрута — часть того же публичного контракта HTTP API,
объявленного в `CLAUDE.md` **необратимым**, — остался незащищённым. Внешняя
программа сломается молча, машина промолчит. Вероятность невысока (мутация
видна в диффе `main.go`), но класс тот же самый.
- Развилка для человека — правка трогает продуктовый код, а `design.md` объявил
это Non-Goal («код `internal/service` и `internal/controller/http` не правится»):
1. **Вынести регистрацию маршрутов** в экспортируемую функцию пакета
`internal/controller/http` (например, `RegisterRoutes(r gin.IRouter, h *TranscribeHandler)`),
звать её из `main.go` и из сборки теста. Цена: ~10 строк продуктового кода,
выход за объявленный Non-Goal задачи, зато контракт маршрута закрыт машиной.
2. **Оставить как есть**, записать в урожай отдельной задачей. Цена: контракт
маршрута остаётся на человеке до следующей задачи, которая и так трогает
`main.go` (например, `json-api-for-spa`).
3. **Оставить как есть и не заводить задачу**, приняв, что маршрут проверяется
глазами. Цена: класс дефекта известен, но не записан нигде — при следующем
промахе оракула не будет.
- Найдено проходом: `review-specs` (там — `minor`, «цена исправления выше цены
дефекта»); оракул мутацией — триаж.
Второго пункта в этой секции нет: остальное либо починено и перепроверено, либо
не имеет цены, оправдывающей правку (см. «Отсеяно» и «Урожай»).
---
## Гипотезы без доказательства
### Понижено оракулом: «зелёный прогон печатает ERROR-строки в stderr» — заявленного последствия нет
- Исходно: `review-code`/техника, `minor`. Часть починена (логгер сборки теста
уведён в `io.Discard`), остаток — `log.Printf("Err: %v", err)` в
`internal/controller/http/transcribe.go:45`.
- Мой оракул: `go test ./internal/controller/http/ -count=1 2>&1 | grep -c 'Err:'`
**0**. `go test` буферизует вывод пакета и на успехе его не печатает. Строка
видна только под `-v` или при прямом запуске собранного бинаря — то есть на
зелёном `task gate` признак ничем не размывается.
- Итог: последствие в формулировке находки не воспроизводится, находка снята.
Остаётся факт «продуктовый код пишет мимо `slog`» — он **уже записан** в
`docs/conventions/logging.md:161` как расхождение, новой находкой не является,
ушёл в promote (механизация правила).
### Понижено: «база `:memory:` без ограничения пула»
- Исходно: `review-code`/техника, `minor`, `Confidence: low`, «сегодня не
срабатывает». Оракула, показывающего отказ, нет ни у прохода, ни у меня.
Починка (`db.SetMaxOpenConns(1)`, одна строка, с комментарием почему) уже
внесена, безвредна и оставлена как есть. Действия не требует.
### Не понижалось, но перепроверено: две `major`-находки `specs`
Обе были заявлены с оракулом и обе починены. Я не поверил на слово и повторил
мутации в копии дерева — см. оракул в секции «Стоит исправить сейчас». Обе
мутации теперь роняют тест. Находки закрыты.
---
## Отсеяно
- **Мёртвая строка `router.MaxMultipartMemory` в сборке теста**
(`transcribe_test.go:138`, названо `review-code` ниже потолка). Проверено:
гиновский `MaxMultipartMemory` читается только в `c.FormFile`/`c.MultipartForm`,
а обработчик зовёт `c.Request.FormFile`, который использует собственный
`defaultMaxMemory` = 32 MiB — то же число. Поведение не меняется ни в тесте, ни
в `main.go`, стоимость следующего изменения не растёт, записанной конвенции нет.
Выброшено, а не смягчено.
- **Проектных ложноположительных (`docs/review.md` → «Типовые
ложноположительные», 4 пункта) в выводах не оказалось ни одного.** Ближайший
сосед — «Файлы и объекты не удаляются, диск растёт» — к отложенной находке про
файл-сироту **не относится**: та запись про отсутствие срока хранения, а
находка — про файл, на который нет ни задачи, ни записи в учёте. По этому
пункту ничего не отсеяно.
---
## Promote candidates
1. **Имена полей публичного ответа судятся по сырому JSON, а не по разобранной
структуре.** Приём в разобранную структуру переименовывает тег вместе с
ожиданием, и проверка теряет способность упасть — это ровно та `major`, что
нашлась здесь. Приём (`map[string]json.RawMessage` + `assert.Contains`)
сработал дважды в одном файле. Дома у правила пока нет: в `docs/conventions/`
файла про тесты нет. Кандидат в новый раздел конвенций.
2. **Стандартный `log` в продуктовом коде — механизировать, а не помнить.**
`docs/conventions/logging.md` уже пишет «**Механизировано:** ничего. Ни
`sloglint`, ни `forbidigo` в `.golangci.yml` не заведено» и поимённо называет
расхождение `internal/controller/http/transcribe.go`. Правило записано и
механизируемо, значит это не находка ревью, а `Promote candidate`: `forbidigo`
на `log.` в `.golangci.yml`.
3. **Регистрация маршрутов — одна на процесс и на тест** (производное от развилки
выше; актуально, если человек выберет вариант 2 или 3).
---
## Урожай
Формулировка → оракул → провенанс. Ничего из этого не чинится в этом изменении.
1. **Приём пишет имя файла пользователя в журнал — нарушение `critical`-инварианта.**
`internal/service/transcribe.go:107`:
`s.logger.Info("Creating transcribe job", "file_id", …, "file_name", fileName, …)`.
Оракул: дословно `CLAUDE.md` § Инварианты — «Содержимое записи остаётся
приватным. Текст расшифровки, **имя файла пользователя** и его сообщение в лог
не пишутся — только длина и идентификаторы. Нарушение необратимо: строки уже
уехали в журнал контейнера. **critical**». Плюс вопрос темы `security` в
`docs/review.md`. Путь общий с Telegram, то есть касается живых записей.
Провенанс: ревью дизайна; **отложено решением человека на чекпоинте**,
записано в `design.md` § Risks. Спека `intake` нормирует только хранилище и о
журнале молчит намеренно.
2. **Отказ чтения метаданных оставляет файл на диске без уборки.**
`internal/service/transcribe.go:129-133` (ветка `metaviewer.GetInfo`) против
`152-157` (ветка `fileRepo.Create`, где `os.Remove` есть). Оракул: чтение кода;
ни задачи, ни записи в учёте под такой файл нет — сопоставить его не с чем.
Провенанс: ревью дизайна; отложено решением человека (правка трогает три ветки
отказа и меняет поведение на диске).
3. **Настоящий `ffprobe` после этой правки не проверяется ничем.**
У `internal/adapter/metaviewer/ffmpeg` своего теста нет; разбор вывода
`ffprobe` не покрыт. Оракул: `go test ./...``? …/adapter/metaviewer/ffmpeg
[no test files]`. Провенанс: `design.md` § Risks, названо прямо. Формально
покрытие не потеряно — прежний тест проверял отказ `ffprobe` и выдавал его за
проверку приёма.
4. **Конвейер задач не покрыт ни одним тестом.**
`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`, `internal/controller/worker`. Три воркера
читают общий `*sql.DB`, теста с параллельным доступом нет. Оракул:
`go test ./...``[no test files]` у `internal/service` и
`internal/controller/worker`. Провенанс: `autotests`, вне scope задачи.
Совпадает со свойством, добавленным в `docs/review.md` («изменённое место
покрыто хоть одним **проходящим** тестом») и с вопросом темы `autotests`.
5. **Опечатка `transcibe` в тексте ошибки теперь закреплена проверкой.**
`internal/controller/http/transcribe.go:46` и
`transcribe_test.go:379``"Failed to create transcibe job"`. Оракул: обе
строки дословно. Исправление меняет тело ответа, то есть **публичный контракт
HTTP API**, объявленный в `CLAUDE.md` необратимым, — значит спрашивается у
человека и не делается походя. Провенанс: `review-specs`, ниже потолка.
Действия сейчас не требует: статус-кво зафиксирован сознательно.
6. **Требование «имя отправителя не попадает в хранилище» проверяется только
благополучными именами.** Проверено мной попутно (вопрос темы `security` из
`docs/review.md`: «не строится ли путь на диске из значения, пришедшего
снаружи»): обхода каталога нет **по построению**`filepath.Ext` не
пересекает разделитель пути, и на входах `../../../etc/passwd.m4a`,
`evil.m4a/../../x`, `/etc/passwd`, `a.b/../../c`, `..` результат всегда
`<uuid><ext>` внутри каталога хранения (оракул: прогон `filepath.Ext` +
`filepath.Join` на этих восьми входах, вывод снят на этом прогоне). Дефекта
нет; недостающее — сторожевой случай, который зафиксирует это свойство.
Провенанс: триаж.
---
## Границы покрытия
### План: темы, дома, глубины
Полностью воспроизведён в сводке выше вместе с исходом каждой темы. Тем без дома
нет, тем без отчёта нет. Дома тем `architecture`, `security` и `operations`
раздел «Инварианты» `CLAUDE.md`, глубина «сверка».
### Какие проходы запускались
- Ревью дизайна: `review-specs`, одна стадия, метка `small`.
- Ревью кода: `review-autotests``review-specs` + `review-code` → триаж. Режим —
по графу, метка `small`.
- **Не запускался `review-basics`**: своих тем у проекта нет — так сказал план.
Следствие названо ниже, в строке про корректор метки.
### Сработавшие потолки
- `review-specs` (код): **3 из 3**, потолок сработал. Ниже среза остались
названными: литералы сообщений об ошибке, включая опечатку `transcibe`;
расхождение сценария «Поля с записью нет» с тем, что делал тест (починено).
- `review-code`: **техника 3/3 — сработал**; **конвенции 1/2**; **инварианты 0/1**.
Ниже среза осталась названной мёртвая строка `router.MaxMultipartMemory`.
- `review-autotests`: **о своём потолке не сообщил**. Это находка о прогоне: по
контракту проход обязан сказать, сколько нашёл, каков был потолок и что
осталось за срезом. Судить, есть ли за его срезом что-то ещё, нечем.
- Триаж: потолок 3/4 не исчерпан (1 блокер, 1 в «стоит исправить»). Из-за потолка
**ничего не выброшено**.
### Что каждый запущенный проход не мог проверить в принципе
Ниже — по отчётам проходов; charter'ы агентов мне дословно не подавались, поэтому
это пересказ их собственных заявлений, а не цитата устава.
- `review-autotests`: судит наличие и зелёность проверок, а не правильность
нормы, которую они проверяют. Прогнал `go test` 5× подряд и с `-race` — флаки
не обнаружен; это отсутствие сигнала на пяти прогонах, а не доказательство
детерминированности.
- `review-specs`: судит соответствие кода дельта-спеке; правильность самой спеки
вне его входа. Приём из Telegram спекой `intake` не описан сознательно — значит,
и не проверялся.
- `review-code`: на метке `small` конвенции сверялись только с
`docs/conventions/README.md`, а `architecture`/`security`/`operations` — только
с записанными инвариантами `CLAUDE.md`.
- Триаж: **ничего нового не находит по определению**. Я не читаю код в поисках
дефектов, я работаю с чужими выводами. Пропуск любого прохода — мой пропуск
тоже; всё, что я могу, — назвать его поимённо, что и сделано выше.
### Что осталось целиком на человеке
Из `docs/review.md` → «Недоступно проверке», **двумя отдельными списками, как
записано**:
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit
и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
- по записи в `docs/review.md` — «Ничего не отключали: проверять пока и не
начинали». **Однако этим изменением список пополняется фактически**: приём
перестал проверяться сквозь настоящий `ffprobe` (решение записано в
`design.md` § Risks и обосновано — прежняя проверка проверяла отказ внешней
программы и выдавала его за проверку приёма). Раздел `docs/review.md` этого
ещё не знает; строку туда добавляет синк документации, не я.
Сверх записанного в проекте — общее, чего не видит ни один прогон: история
инцидентов, поведение под реальным потоком, поведение внешних систем в их
версиях, завязка потребителей на текущее поведение и вопрос «а нужна ли эта
функциональность вообще».
### Каких документов проекта не хватило
Строкой на каждый, с причиной — деградация поразрядная:
- `docs/conventions/` **про тесты файла нет**: конвенции покрывают конфиг, базу,
ошибки, журнал и веб-UI. Изменение целиком про тесты, и сверять его форму было
не с чем — отсюда promote-кандидат №1, а не находка.
- `docs/adr/` **пуст**: только `README.md` и `template.md`, ни одного решения. См.
обязательную строку 1 ниже.
- `docs/research/` — только `README.md`, записанных замеров нет. См. строку 2.
- `docs/review.md` § «Как настроен конвейер» **устарел с этого прогона**: там
написано «Конвейера ревью в проекте пока нет: плагин не подключён, ни одного
прогона не было». Прогон был — этот. Отсев ложноположительных при этом **не был
слепым**: раздел «Типовые ложноположительные» заполнен наперёд, четыре пункта, и
я им пользовался.
- `docs/security.md` в проекте **есть**, но на метке `small` план отправил тему
`security` в инварианты `CLAUDE.md`, и как дом темы `security.md` не
открывался. См. строку 5 ниже.
### Четыре строки, которых не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr/*` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит сверка
документации (скилл `av-dev-docs:healthcheck`), а не ревью. Здесь у этого есть
и вторая сторона: каталог решений пуст, сверять было бы не с чем.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
процессный. Всякое число в этом отчёте снято командой на этом прогоне; чисел
без приложенной команды в отчёте нет.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против просто распространено» на этом
прогоне не спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру.
«Не знаю, чего не знаю» здесь никто не достаёт: например, вопрос «а верна ли
сама форма подстановки в сборке теста» не задал никто, кроме автора дизайна.
### Пятая строка — следствие метки `small`
Темы `security`, `operations` и `architecture` сверялись **только с записанными
инвариантами `CLAUDE.md`**; дома этих тем (`docs/security.md`,
`docs/architecture.md`, `docs/conventions/logging.md` как источник норм журнала)
не открывались. Свойство, которого нет в семи пунктах инвариантов, на этом
прогоне не проверил никто.
### Отдельно про корректор метки
`review-code` метку `small` подтвердил, сигнала о занижении не подал.
`review-basics` **не запускался**, поэтому второго, независимого от `review-code`
подтверждения метки нет. Согласия двух проходов здесь не было бы и при запуске:
несколько агентов — один источник, высказавшийся несколько раз; совпадение
подняло бы приоритет, но не `confidence`.
@@ -0,0 +1,90 @@
## ADDED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в каталоге хранения одним файлом
#### Scenario: Поля с записью нет
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Имя файла в хранилище
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла на диске расширение было всегда.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** файл в каталоге хранения имеет расширение `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** файл в каталоге хранения имеет расширение `.audio`
### Requirement: Отказ чтения метаданных
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в
тело ответа: она принадлежит журналу, а не отправителю.
#### Scenario: Источник метаданных вернул ошибку
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с этой записью
- **THEN** ответ имеет код `500`
- **AND** задача расшифровки не заводится
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
#### Scenario: Задача найдена
- **WHEN** программа спрашивает состояние заведённой задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Расшифровки ещё нет
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
@@ -0,0 +1,60 @@
## 1. Подстановки в сборке теста
- [x] 1.1 Завести в тестовом пакете подставной `contract.AudioMetaViewer`: отдаёт
заданную длительность либо заданную ошибку
- [x] 1.2 Завести подставной `contract.AudioFileConverter` — конвертацию эти
проверки не зовут
- [x] 1.3 `setupTestRouter` принимает каталог хранения и источник метаданных
снаружи; `ffmpegmv` и `ffmpegconv` из импортов пакета уходят
- [x] 1.4 Каталог хранения каждому случаю даёт `t.TempDir()`; `os.Chdir` в файле
не остаётся ни одного
## 2. Сборка запроса
- [x] 2.1 Вспомогательная функция собирает `multipart`-запрос из имени файла и
байтов, а не из пути на диске
- [x] 2.2 Ни один случай не создаёт файлов в текущем каталоге
## 3. Проверки приёма
- [x] 3.1 Приём записи: код `201`, поле `job_id` непустое, поле `status` равно
`created`, содержимое целиком лежит в каталоге хранения одним файлом
- [x] 3.2 Поля `audio` нет: код `400`, сообщение об отсутствии записи
- [x] 3.3 Запись нулевой длины: код `201`
- [x] 3.4 Расширение из имени отправителя: `.m4a`, `.mp3`, `.wav`, а имя без
расширения даёт `.audio`
- [x] 3.5 Источник метаданных вернул ошибку: код `500`, задача не заведена
- [x] 3.6 Опрос готовности: найденная задача даёт `200` с полями `job_id`,
`status` и `created_at`; неизвестный идентификатор даёт `404`
- [x] 3.7 Поля `transcription_text` в ответе нет, пока текста нет — проверяется
по сырому JSON, а не по разобранной структуре
## 4. Линтер
- [x] 4.1 Снять исключение `errcheck` для `_test.go` в `.golangci.yml`
- [x] 4.2 `golangci-lint run` не даёт замечаний сверх четырёх объявленных долгом
в `CLAUDE.md`
## 5. Гейт и приёмка
- [x] 5.1 `go test ./...` зелёный
- [x] 5.2 `task gate` не краснее объявленного долга: из двух известных отказов
остаётся только `golangci-lint`
- [x] 5.3 Каждый критерий приёмки проверен своим оракулом, исход записан
- [x] 5.4 Снять `go test ./...` из списка объявленных долгов в `CLAUDE.md`,
раздел «Гейт»: долг закрыт, и оставленная запись стала бы оправданием для
любого будущего красного `go test` в этом пакете
## Критерии приёмки
Дословно из записи задачи `http-handler-tests-never-green`:
- `go test ./...` зелёный на чистом клоне без ручной подготовки файлов. Оракул —
`git clone` во временный каталог и `go test ./...`.
- Тест приёма не зависит от установленного `ffprobe`: метаданные даёт подставной
`AudioMetaViewer`. Оракул — прогон с временно переименованным `ffprobe` в
`PATH`.
- Отказ разбора метаданных проверяется отдельным случаем и ожидает `500`, а не
`201`. Оракул — тот же тест на подставном, возвращающем ошибку.
- Тесты не меняют рабочий каталог процесса. Оракул — `grep -n 'os.Chdir'
internal/controller/http/transcribe_test.go` пуст.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,146 @@
## Context
Общий шаг заведения задачи пишет журнальную строку о принятой записи и кладёт в
неё три поля: идентификатор файла, имя, данное отправителем, и путь, по которому
запись легла на диск. Уровень строки — `INFO`, то есть в боевой настройке она
пишется всегда. Через этот шаг проходит и запись из Telegram, и запись по HTTP,
поэтому строка общая для обоих входов.
**Имя, данное отправителем, доходит до этой строки только по HTTP.** Приём по
HTTP отдаёт в сервис имя из формы запроса; приём из Telegram отдаёт путь,
выданный самим Telegram (`voice/file_5.oga`), а настоящее имя документа дальше
проверки типа файла не идёт. Утечка сегодня одна, и она на входе по HTTP; правка
всё равно делается на общем шаге, чтобы второй вход не мог её обойти.
`docs/conventions/logging.md` запрещает имена файлов пользователя прямо, и это же
объявлено критическим инвариантом. Ни линтером, ни проверкой запрет сегодня не
выражен: `sloglint` в наборе не включён, а проверки приёма журнал выбрасывают.
## Goals / Non-Goals
**Goals:**
- Убрать имя отправителя из журнала приёма — на общем шаге, то есть для обоих
входов разом.
- Оставить прослеживаемость: по журналу по-прежнему видно, какой файл заведён,
с каким расширением и какого размера запись.
- Сделать запрет проверяемым на обоих путях приёма — успешном и отказном, — и
проверяемым **по значению**, а не по имени журнального поля.
**Non-Goals:**
- Сверка остальных журнальных строк проекта с запретами. Прочие строки здесь не
пересматриваются — это отдельная работа.
- Нормализация расширения **в имени файла на диске**. Раскладка каталога записей
объявлена необратимой, и меняется она решением человека. Расширение там
остаётся пришедшим, а остаток записан строкой в `docs/security.md`. Метки
метрик под этот отказ не подпадают — про них решение ниже.
- Выражение запрета линтером (`sloglint`, `forbidigo`). Набор линтеров задача не
меняет.
- Перевод `log.Printf` в HTTP-транспорте на общий логгер. Расхождение записано в
конвенции журналирования и живёт своей жизнью; проверка его поток **видит**,
но чинить его здесь не будем.
## Decisions
**Поле с именем убирается, а не заменяется производной от имени.**
Рассматривались два способа сохранить корреляцию по имени: хеш имени и его
длина. Хеш — та же приватная величина в другой записи: по нему имя восстанавливают
перебором, а при повторной отправке одной записи он ещё и связывает отправки
между собой. Длина имени не отвечает ни на один вопрос разбора. Отказано обоим:
корреляцию в проекте держат идентификаторы сущностей, а не имена, и это уже
записано конвенцией журналирования.
**Расширение остаётся тем же полем, что и сейчас, — путём файла в хранилище.**
Путь несёт собственное имя файла: идентификатор плюс расширение. Отдельное поле
под расширение завело бы второй дом одному факту, и два поля начали бы
расходиться на первой же правке выбора расширения. Отвергнуто.
**Размер принятой записи в журнале уже есть** — соседняя строка об успешно
загруженной записи несёт идентификатор файла и размер в байтах. Заводить её
заново не требуется; требование о прослеживаемости она закрывает как есть.
Величина названа байтами во всех трёх местах — в требовании, здесь и в критериях
приёмки: рядом в той же строке лежит длительность, и «длина» читалась бы как она.
**Оракул — перехваченный журнал в проверке приёма по HTTP, и он видит все три
пишущих потока.** Окружение проверки сегодня отдаёт журнал в никуда, чтобы
строки отказа не путались с признаком красного гейта. Вместо «в никуда» журнал
уходит в буфер, и в тот же буфер сводятся: структурный логгер сервиса,
стандартный `log`, через который HTTP-транспорт пишет мимо `slog`, и middleware
запроса — ради него тестовый роутер собирается той же цепочкой, что и боевой.
Иначе квантор требования («ни одна журнальная запись приёма») был бы шире
оракула, и утечка через непокрытый поток оставила бы проверку зелёной.
**Перехват стандартного `log` удерживается положительным утверждением.** Проверка
отказного пути сперва требует, чтобы строка транспорта в буфере **была**, и лишь
затем — чтобы имени в нём не было. Без этого снятие перехвата не уронило бы
ничего, а оракул сузился бы вдвое молча. Перехват при этом процессный, поэтому
`t.Parallel()` в этом файле запрещён — сказано строкой рядом с кодом.
**Буфер свой на каждый случай, и утверждение отбирается по идентификатору этого
прогона.** Общий на пакет буфер сделал бы исход проверки функцией от соседних
случаев: «поля на месте» прошло бы на чужой строке, а «маркера нет» ложно
покраснело бы от чужой. Плюс параллельные случаи писали бы в один
`bytes.Buffer` — гонка на проверке критического инварианта, то есть флаки, а
флаки на таком месте снимают целиком.
**Проверка судит по значению маркера, а не по имени поля.** Маркер — уникальная
ASCII-строка, которой нет в остальном выводе. Поиск по имени поля (`file_name`)
не поймал бы возвращённое имя под другим ключом, а прецедент такого класса в
проекте уже записан: проверка приёма от 2026-08-11 была зелёной и не ловила
ничего. Отсюда же обязательный шаг приёмки — **мутация**: вернуть имя в журнал
под другим ключом, убедиться, что проверка краснеет.
**Отказной путь проверяется наравне с успешным.** Приём назван конвенцией
журналирования логирующей границей: ошибка попадает в журнал полем `error`
вместе со всей цепочкой `%w`. Обёртка вида `fmt.Errorf("сохранение %s: %w", …)`
где угодно ниже отдаст имя именно там, и проверять только успех значит не
проверять этот путь вовсе.
**Косвенные носители имени названы поимённо.** Текст ошибки — закрыт вторым
сценарием. Путь на диске строится из идентификатора и расширения; ключ объекта в
Object Storage берётся из имени **после конвертации**, то есть всегда
`идентификатор.ogg`, — наружу к внешнему сервису хвост не уезжает. Колонка
`error_text` журналом не является и этой задачей не трогается.
**Третий носитель хвоста — метка метрики, и он закрыт приведением.** Это решение
контрольной точки, принятое **после ревью кода**: проход построил путь целиком —
запись с именем `запись.тайное-слово` кладёт хвост меткой, а страница метрик
отдаётся без проверки отправителя, то есть читает её кто угодно, и то же
значение оседает в хранилище метрик. Канал оказался шире того, который задача
закрывала.
Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как «починено»;
приводить расширение везде, включая имя файла на диске, — раскладка каталога
записей объявлена необратимой и меняется решением человека, а не по ходу
починки журнала. Принято: приводить **только значение метки** — расширение из
закрытого перечня идёт приведённым к нижнему регистру, всё прочее становится
одним общим значением. Имя на диске не трогается вовсе. Тем же ограничением
снимается рост числа временных рядов, которым иначе распоряжается анонимный
отправитель.
**Приведение проверяется по реестру метрик, а не по самой функции.** Проверка
функции в отрыве от точек употребления зелёная и при снятом приведении: правка
места вызова вернула бы хвост наружу молча. Поэтому проверка гонит приём с
незнакомым расширением и читает реестр: хвоста в метках нет, общее значение
есть, а на диске расширение осталось пришедшим.
## Risks / Trade-offs
**Расширение приходит из имени отправителя, и в журнал оно попадает как есть**
имя вида `запись.тайное-слово` отдаёт `тайное-слово` расширением, и оно уедет в
журнал вместе с путём. Смягчение: остаток записывается строкой в
`docs/security.md`, чтобы закрытие задачи не читалось как «канал закрыт
целиком». Нормализация расширения остаётся отдельной работой со своим
требованием.
**Проверкой накрыт только вход по HTTP** → приём из Telegram делит с ним общий
шаг, и правка достаётся ему той же строкой кода, но своей проверки у него нет.
Смягчение: правка делается на общем шаге, а не в транспорте, — обойти её со
стороны Telegram нечем. Имени, данного человеком, оттуда сегодня и не приходит.
**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
принятая запись. Правило линтера — кандидат в отдельную задачу.
@@ -0,0 +1,46 @@
## Why
Приём кладёт в журнал имя файла, которое дал отправитель, на каждой принятой
записи. Имя записи из семейного архива — такое же содержимое личной переписки,
как и сам текст расшифровки; инвариант приватности объявляет такую запись
критической и необратимой, потому что строка уже уехала в журнал контейнера.
Доходит это имя до сервиса сегодня только по HTTP: из Telegram приходит путь,
выданный самим Telegram, а не имя человека. Строка журнала при этом общая для
обоих входов, и запрет пишется на неё, а не на транспорт.
## What Changes
- Журнал приёма перестаёт нести имя, данное отправителем. Правка ложится на
общий шаг заведения задачи, через который идут оба входа.
- Прослеживаемость приёма сохраняется: идентификатор записи, её расширение и
размер в байтах в журнале остаются, по ним путь записи собирается отбором.
- Запрет становится проверяемым, и проверяется он на успехе и на отказе: приём
прогоняется с записью, чьё имя содержит опознаваемую строку, и журнал этой
строки не содержит.
- **Метка метрики перестаёт нести кусок имени.** Расширение — хвост после
последней точки — берётся из имени отправителя и уезжало меткой на страницу
метрик, которая отдаётся кому угодно. Теперь оно приводится к перечню
известных форматов, всё прочее становится одним общим значением. Решение
принято контрольной точкой уже после ревью кода, которое построило этот путь.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `intake`: приём получает требование о том, чего его журнал нести не вправе, и
о том, что в нём остаётся ради прослеживаемости.
## Impact
- `internal/service/transcribe.go`, общий шаг заведения задачи — журнальные
строки приёма и обе метки, несущие расширение;
- `internal/metrics` — приведение расширения к перечню известных форматов;
- проверка приёма: новый прогон с опознаваемым именем и разбором перехваченного
журнала;
- `docs/conventions/logging.md` уже запрещает имена файлов пользователя — правки
не требует, разве что строкой о том, чем имя заменено.
@@ -0,0 +1,802 @@
# Триаж ревью кода: `no-user-filename-in-log`
Документ ведётся по кругам. Наверху — последний круг, ниже приложением — отчёт
первого круга целиком, как он был написан. История не переписывается: исход
каждой находки первого круга проставлен отдельным разделом, а не правкой её
текста.
---
# Круг 2 (после контрольной точки о метке метрики)
## Сводка
- **Change:** `no-user-filename-in-log`. База диффа `origin/master`, судится
рабочее дерево (`git diff`) плюс три файла не под git:
`internal/metrics/format_label.go`, `internal/metrics/format_label_test.go`,
`internal/service/format_label_test.go`. Итого 5 изменённых файлов
(+258/17) и 3 новых.
- **Размер:** среднее. **Сложность:** знакомое. **Метка: medium** — максимум по
осям, не менялась между кругами. Второй круг добавил файл в
`internal/metrics`, но проектный триггер «новая метрика в `internal/metrics`»
(`docs/review.md`, «Триггеры метки») опускает до `small` именно новую
**метрику**; здесь новой метрики нет, добавлено приведение значения метки.
Метка остаётся `medium`.
- **Режим прогона:** по графу. Второй круг: `specs`, `code`, `basics`, `triage`.
- **Гейт:** красный **только объявленным долгом**. Проверено мной самим:
`go build ./...`, `go vet ./...`, `gofmt -l .`, `go test ./...` зелёные;
`golangci-lint run` даёт ровно 4 знакомых замечания (`speechkit.go:55`,
`main.go:124`, `worker.go:51`, `transcribe.go:395`);
`openspec validate no-user-filename-in-log --strict``is valid`.
`go test -race -count=5 ./internal/controller/http/``ok, 1.663s`.
Новых красных шагов нет.
- **Находок на входе второго круга:** 15 (specs 6, basics 3, code 6) + 1
кандидат в правила. За два круга — 32.
**После дедупликации по причине и отсева:** 3 в первых двух секциях
(1 блокирующая, 2 к исправлению), 3 гипотезы, 5 кандидатов в правила.
### Находка о прогоне: `autotests` на втором круге не запускался
Второй круг прогнал `specs`, `code` и `basics`. Проход `autotests`**нет**, и
это его тема: круг добавил 130 строк в `internal/controller/http/transcribe_test.go`
и два новых тестовых файла целиком. Дом темы (`CLAUDE.md`, «Гейт» и «Команды»)
второй круг не открывал никто.
Это не абстрактный пробел: **блокирующая находка №1 ниже — ровно тот класс,
который ищет `autotests`**, и нашёл её я мутацией, а не проход. Судить, сколько
ещё такого осталось в добавленных проверках, нечем: я не читаю код в поисках
дефектов, я проверяю чужие выводы, а выводов по теме `autotests` за второй круг
не поступало.
### Сигнал о заниженной метке
- **Круг 1:** `review-code` пришёл и метку заниженной **не** считает. От
`review-basics` сигнала не было — ни «верна», ни «занижена».
- **Круг 2:** сигнала не пришло **ни от одного** прохода — ни от `review-code`,
ни от `review-basics`. Отличить «возражений нет» от «не сказано» по молчанию
нельзя, и я этого не делаю.
### План разметки против исхода — оба круга
План не менялся. Своих тем проекта сверх ядра нет, тем без дома нет.
| тема | дом | глубина | закрывает | круг 1 | круг 2 |
| --- | --- | --- | --- | --- | --- |
| requirements | `openspec/specs/intake/spec.md` + дельта change | разбор | specs | **закрыта**, 4 находки | **закрыта**, 6 находок |
| autotests | `CLAUDE.md`, «Гейт» и «Команды» | — | autotests | **закрыта**, 0 находок + 1 promote | **отчёта не пришло — проход не запускался** |
| conventions | `docs/conventions/logging.md` + весь каталог | разбор | code | **закрыта**, 6 находок | **закрыта**, 6 находок |
| architecture | `docs/architecture.md`, «Единые точки проекта» + `docs/passport.md` | разбор | basics | **закрыта**, ответ по вопросу темы + 1 находка | **закрыта**, 1 находка (дом правила) |
| security | `docs/security.md`, «Куда уходит содержимое записи», «Из чего строятся пути и ключи» | разбор | basics | **закрыта**, 1 находка (главная) | **закрыта**, подтверждение: канал наружу перебран поимённо |
| operations | `docs/architecture.md`, «Эксплуатация» + `docs/database.md` | разбор | basics | **закрыта**, ответы по вопросам темы | **закрыта**, 2 находки (умолчание, видимость настоящего формата) |
---
## Блокирует мердж
### 1. Второй употребитель приведения метки оракулом не закрыт: снятие `FormatLabel` на шаге конвертации не роняет ничего
- **Где:** `internal/service/transcribe.go:212`
`WithLabelValues(metrics.FormatLabel(srcExt), "ogg", strconv.FormatBool(err != nil))`.
Критерий `tasks.md` 2а.2 («Обе метки, несущие расширение, идут через
приведение») отмечен выполненным.
- **Severity:** major. Не критично тем, что код **сегодня верен**: приведение на
месте, хвост наружу не идёт. Критично то, что удержано оно ничем, а чек-лист
утверждает обратное.
- **Оракул (мутация на копии дерева, прогнана на этом прогоне):**
```
# копия дерева, sed по строке 212: FormatLabel(srcExt) -> srcExt
go test ./internal/...
MUT[M2 приведение снято у метки конвертации]: ЗЕЛЁНАЯ (мутация не поймана)
```
Для сравнения — тот же приём на первом употребителе краснеет:
```
MUT[M1 приведение снято у метки размера приёма]: КРАСНЕЕТ
--- FAIL: TestCreateTranscribeJob_MetricLabelCarriesNoSenderName
```
- **Что именно уходит этой меткой:** `srcExt` строится из `srcFile.FileName`
(`transcribe.go:195`), а это имя файла в хранилище вида
`<uuid>.<хвост имени отправителя>`. То есть в метку `source_format` метрики
`transcriber_conversion_duration_seconds` попадает тот же приватный хвост, что
и в метку приёма, и уезжает он на тот же анонимный `GET /metrics`
(`main.go:216`). Канал по свойствам идентичен закрытому.
- **Почему это блокирует, а не «стоит исправить»:** `design.md` этого change сам
объявил такую проверку недостаточной — дословно: «Проверка функции в отрыве от
точек употребления зелёная и при снятом приведении: правка места вызова
вернула бы хвост наружу молча». Решение принято, реализовано для одной точки
из двух, а чек-лист отмечает обе. Это записанное свойство проекта —
`docs/review.md`, «Типовые узлы / Любой узел»: «проверка **способна упасть**…
Признак ищется мутацией», и журнальная запись 2026-08-11 об этом же классе.
Ранжирую первым по правилу молчания: следующий, кто уберёт `FormatLabel` со
строки 212 как лишний вызов, сузит оракул критического инварианта, и никто не
узнает.
- **Чего эта находка НЕ утверждает:** «`/metrics` открыт без аутентификации» —
типовое ложноположительное проекта (`docs/review.md`, «Типовые
ложноположительные», пункт про HTTP API). Новое здесь не открытость, а
неудержанность приведения на второй точке.
- **Действие: развилка.**
> Приведение расширения к перечню стоит в двух местах, а оракул — только на
> одном. Мутация на втором (`transcribe.go:212`, метка `source_format` шага
> конвертации) зелёная: снятие приведения не роняет ни одной проверки.
> Своего тестового окружения у `internal/service` нет вовсе — есть только
> `format_label_test.go` на 19 строк. Что делаем до мерджа?
>
> **(а)** Мержим как есть: пробел записываем строкой в `design.md` разделом
> Risks и вешаем на существующую задачу `tasks/items/pipeline-step-tests.md`
> — она заведена ровно про непокрытые `FindAndRunConversionJob` и соседей.
> Чек-лист `tasks.md` 2а.2 при этом надо переформулировать: «приведение стоит
> на обеих точках, оракул — на одной».
> **(б)** Строим окружение проверки для `FindAndRunConversionJob`: репозитории
> SQLite, каталог хранения, подставной конвертер. Цена сопоставима с самой
> задачей и вылезает за её scope.
> **(в)** Убираем возможность обойти приведение по построению: прячем
> `InputFileSizeHistogram` и `ConversionDurationHistogram` за функциями
> `internal/metrics`, которые сами зовут `FormatLabel`. Тогда сырое расширение
> передать меткой нечем, и обе точки закрывает уже существующая проверка
> реестра. Меняется экспортируемая поверхность пакета `internal/metrics` —
> пакет внутренний, публичного контракта это не трогает, но это правка сверх
> заказанного объёма.
---
## Стоит исправить сейчас
### 2. Комментарий оракула ссылается на механизм, которого в коде нет
- **Где:** `internal/controller/http/transcribe_test.go:492` — «снятие
`log.SetOutput` из окружения оставило бы проверку зелёной». Тот же текст в
`tasks.md`, пункт 2.1: «в него же перенаправляется вывод стандартного `log`».
- **Оракул:** `grep -n "log.SetOutput" internal/controller/http/transcribe_test.go`
отдаёт единственное совпадение — саму эту строку комментария. В окружении
стоит `slog.SetDefault(logger)` (`:167`), как в `main.go:50`. Механизм заменён
в этом же круге по находке прохода `code` — комментарий и чек-лист за правкой
не поехали.
- **Почему сейчас:** причина ровно та же, что у находки №3 первого круга
(`src_ext`, метки не существует), и лечится так же — одним словом. Проза,
называющая несуществующий механизм, сбивает следующего: он не найдёт
`log.SetOutput`, решит, что утверждение мёртвое, и снимет его. Утверждение
живое — мутация подтверждает:
```
MUT[M4 slog.SetDefault снят]: КРАСНЕЕТ
--- FAIL: TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure
```
- **Действие: инлайн.** В обоих местах заменить `log.SetOutput` на
`slog.SetDefault` и уточнить формулировку: вывод стандартного `log` уходит в
буфер не перенаправлением, а тем, что `slog.SetDefault` сажает его в `msg`
записи `slog`.
### 3. Значение `other` нормировано спекой дословно, но ни одна проверка к литералу не привязана
- **Где:** `internal/metrics/format_label.go:6` — `const OtherFormatLabel = "other"`;
дельта-спека, требование «Метка метрики несёт только известное расширение»:
«всякое другое MUST заменяться единым значением `other`».
- **Оракул (мутация, прогнана):**
```
# const OtherFormatLabel = "other" -> const OtherFormatLabel = ""
MUT[M11 общее значение пустое]: ЗЕЛЁНАЯ (мутация не поймана)
```
Обе проверки — и `TestFormatLabel`, и
`TestCreateTranscribeJob_MetricLabelCarriesNoSenderName` — сверяются с самой
константой, то есть утверждают тавтологию. Значение метки — величина, которую
видит человек в панели снаружи; спека называет её дословно, а код может
сменить её молча, включая на пустую строку, при которой метка из выборок
Prometheus фактически исчезает.
- **Почему сейчас, а не в гипотезы:** это не догадка, а прогнанная мутация, и
закрывается одной строкой в `internal/metrics/format_label_test.go`:
`if OtherFormatLabel != "other" { t.Fatal(...) }` — либо заменой `want:
OtherFormatLabel` на `want: "other"` в трёх случаях таблицы.
- **Действие: инлайн.**
---
## Гипотезы без доказательства
- **Запрет `t.Parallel()` держится комментарием** (перенесено с первого круга и
остаётся верным; механизм только сменился). Теперь процессная подмена — это
`slog.SetDefault`, и ничто, кроме текста рядом, не мешает следующей проверке в
этом файле стать параллельной; тогда перехват уедет к соседу. Оракула нет:
воспроизвести можно только внесением `t.Parallel()`, то есть той самой будущей
правкой. `Confidence: medium`, severity не выше `minor`.
- **Позитивное утверждение `require.Contains(journal, msgTransportErr)`
привязано к расхождению, которое конвенция объявляет подлежащим устранению**
(`log.Printf` в HTTP-транспорте мимо `slog`). Когда расхождение починят,
проверка отказа покраснеет по причине, не связанной с приватностью. Вынос
текста в константу с комментарием про долг (сделан вторым кругом) снижает цену
правки, но событие в будущем оракулом не закрывает. `minor`.
- **Приём из Telegram своего оракула не получил ни на одном круге.** Правка
достаётся ему общим шагом `createTranscribeJob`, и обойти её со стороны
Telegram нечем — но это рассуждение по коду, а не прогон. Объявлено риском в
`design.md`. `minor`.
---
## Promote candidates
- **Правило линтера на ключи `file_name`/`filename` в вызовах логгера**
(`sloglint` либо `forbidigo`). Пришло от `autotests` на первом круге;
`design.md` объявил это non-goal задачи.
- **Свойство в `docs/review.md`, «Типовые узлы / Любой узел»:** оракул ставится
**на каждой точке употребления**, а не на самой функции и не на одной из
точек. Обобщение трёх находок подряд — неудержанного middleware (круг 1, №2),
неудержанного `log`-потока (круг 1, починено) и неудержанной точки конвертации
(круг 2, №1). Три повторения одного класса за один change — заявка на
записанное правило, а не на разовую починку.
- **Конвенции о метриках в проекте нет вовсе.** `docs/conventions/` — пять
файлов: `config`, `database`, `errors`, `logging`, `web-ui`. Что можно класть в
метку, кто отвечает за кардинальность, как объявлять смену формы значения —
дома нет. Второй круг завёл строку в `docs/architecture.md`, «Единые точки
проекта», но это указатель на реализацию, а не правило. Пришло от `code`.
- **Нормализация расширения, взятого из имени отправителя, в журнале и в имени
файла на диске** — отдельной задачей, с явным решением человека про раскладку
`data/files` (`CLAUDE.md` называет её необратимой). Остаток записан прозой в
`docs/security.md`; задачи на него нет.
- **Смена формы значения метки (`.mp3` → `mp3`) записана в `docs/security.md`.**
Дом спорный: это факт эксплуатации, а не модели угроз, и человек, у которого
опустела панель, полезет в `docs/architecture.md`, «Эксплуатация». Не находка
— правило о том, где объявляются несовместимые смены формы метрик, входит в
предыдущий пункт.
---
## Что из починенного починено верно и не ослаблено
Проверено мутациями на копии дерева (`git ls-files` + три файла не под git), не
со слов. Базовый прогон копии зелёный, откат подтверждён.
| мутация | исход | что этим удержано |
| --- | --- | --- |
| приведение снято у метки размера приёма | **КРАСНЕЕТ** (`…MetricLabelCarriesNoSenderName`) | круг 2, specs #1 — для точки приёма |
| приведение снято у метки конвертации | **ЗЕЛЁНАЯ** | **не удержано — находка №1** |
| имя отправителя вернулось под ключом `src` | **КРАСНЕЕТ** (обе проверки запрета) | круг 1: поиск идёт по значению, не по ключу |
| `slog.SetDefault` снят из окружения | **КРАСНЕЕТ** (`…NotLoggedOnFailure`) | круг 2, code #4 — боевая цепочка журнала |
| middleware не подключён к тестовому роутеру | **КРАСНЕЕТ** (`…SenderFileNameNotLogged`) | круг 1, находка №2 |
| строка приёма понижена до `DEBUG` | **КРАСНЕЕТ** (`…JournalTracesRecord`) | круг 1: адресат записи |
| умолчание сервиса выведено из перечня | **КРАСНЕЕТ** (`TestDefaultAudioExtIsKnownToMetrics` + `…DifferentFileExtensions`) | круг 2, basics #1 — дубль умолчания |
| вторая строка приёма в журнале | **КРАСНЕЕТ** (`…JournalTracesRecord`) | круг 1, находка №4 — «ровно одна запись» |
| приведение регистра снято | **КРАСНЕЕТ** (`TestFormatLabel`) | круг 2, specs #5 |
| `oga`, `opus`, `mp4` выведены из перечня | **КРАСНЕЕТ** (`TestFormatLabel`) | круг 2, code #1 — голосовые Telegram |
| `OtherFormatLabel` стал пустым | **ЗЕЛЁНАЯ** | **не удержано — находка №3** |
Дополнительно проверено поимённо, а не со слов:
- **Круг 1, находка №3 закрыта.** `docs/security.md` называет метки
`file_extension` у `transcriber_input_file_size_bytes` и `source_format` у
`transcriber_conversion_duration_seconds`. Сверено с
`internal/metrics/metrics.go:24` и `:44` — совпадает.
`grep -rn "src_ext"` по коду и документам совпадений не даёт (остались только
упоминания в архиве этого же отчёта).
- **Круг 2, specs #4 закрыта.** `design.md` держит решение о метке в Decisions,
с записью развилки и двух отвергнутых вариантов (оставить как есть; приводить
везде, включая диск). `proposal.md` согласован.
- **Круг 2, specs #3 и basics #2 закрыты.** Перечень назван поимённо в
требовании спеки (14 форматов + `audio`), дом правила — строка в
`docs/architecture.md`, «Единые точки проекта».
- **Ослаблений не найдено.** Ни одна проверка первого круга не стала слабее,
`go test ./...` зелёный, `go test -race -count=5` зелёный, новых замечаний
линтера нет, `openspec validate --strict` — valid.
---
## Что осталось непочиненным и почему
Ничего не выброшено молча. Полный список:
1. **Точка конвертации не удержана оракулом** — находка №1, развилка, решение за
человеком. Единственное, что блокирует мердж.
2. **Комментарий про `log.SetOutput`** — находка №2, инлайн, одно слово в двух
местах.
3. **Литерал `other` не привязан к спеке** — находка №3, инлайн, одна строка.
4. **Остаток «хвост расширения остаётся в журнале» задачей не заведён.** Записан
прозой в `docs/security.md` и в Risks `design.md`. Не починено **намеренно**:
заведение задач принадлежит скиллу задач, а не конвейеру ревью. Идёт урожаем
(Promote candidates, пункт 4).
5. **Конвенции о метриках нет** — урожай, Promote candidates, пункт 3. Не
находка об этом коде.
6. **Расширение в имени файла на диске не нормализуется** — решение принято
человеком на контрольной точке и записано отвергнутым вариантом в
`design.md`. Раскладка `data/files` объявлена необратимой в `CLAUDE.md`.
Закрыто как решение, не как пробел.
7. **Сценарий «Известное расширение идёт как есть» назван неточно** — тело
сценария (`sample.MP3` → `mp3`) описывает приведение регистра, заголовок
говорит «как есть». Выброшено по отсеву вкусовщины: поведения не меняет, на
стоимость следующей правки не влияет, записанной конвенции не нарушает —
текст требования выше разночтение снимает дословно.
Потолок первых двух секций **не срабатывал**: 1 из 3 и 2 из 4. За срезом не
осталось ничего.
---
## Границы покрытия
### План
Шесть тем, у каждой есть дом и глубина; все шесть перечислены в таблице выше с
исходом по каждому кругу. Тем без дома нет. Своих тем проекта сверх ядра нет.
**Тема без отчёта одна: `autotests` на втором круге** — проход не запускался,
дом темы (`CLAUDE.md`, «Гейт» и «Команды») второй круг не открывал никто, а
добавленный кругом код — это на 100% тестовый код.
### Проходы
- **Круг 1**, метка `medium`, режим «по графу»: `review-autotests`,
`review-specs`, `review-code`, `review-basics` (темы `security`, `operations`,
`architecture`, глубина разбор), `review-triage`.
- **Круг 2**, та же метка и режим: `review-specs`, `review-code`,
`review-basics`, `review-triage`.
- **Не запускались:** `review-autotests` на втором круге — причина мне не
сообщена (в задании сказано «`specs`, `code`, `basics` прогнаны заново», без
обоснования пропуска). Проходы ревью дизайна (`specs`, `rubric` на предложении)
отработали до кода и в этот прогон не входят.
### Чего в конвейере нет вовсе
Четыре строки, которые не принесёт ни один проход:
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит сверка
документации (`av-dev-docs:healthcheck`), а не ревью. В каталоге лежат четыре
ADR, включая `ADR-2026-08-11-stub-adapters-in-tests.md`, — ни один из них
этим прогоном не читался как источник требований.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
процессный. Все числа в этом отчёте сняты на этом прогоне, команды приложены.
3. **Поимённая сверка с руководством по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» — например, `map[string]struct{}`
против `slices.Contains` в `format_label.go`, или уместность тавтологичного
сравнения с константой — не спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
знаю, чего не знаю» никто не достаёт.
### Чего запущенные проходы не могли проверить в принципе
- Ни один проход не гонял сервис на настоящих данных: `testdata` в проекте нет
по запрету `CLAUDE.md`, реальные ключи Yandex Cloud под запретом, боевую БД и
`data/files` трогать нельзя. Все оракулы — синтетический вход и мутации.
Для находки про внешний формат это существенно: перечень из 14 расширений
собран рассуждением о том, что выдаёт Telegram и что берёт `ffmpeg`, и **ни на
одном настоящем файле не проверен**.
- `specs` судит код против заказанного поведения и не ищет дефектов вне него;
`code` судит технику и конвенции и не судит требования; `basics` идёт по трём
темам и только по их записанным домам; `autotests` судит прогон и мутации, а
не смысл проверок, — и второй круг не судил вовсе.
- Приём из Telegram своей проверки не получил ни на одном круге (объявлено
риском в `design.md`).
- Шаг конвертации (`FindAndRunConversionJob`) не покрыт ни одним тестом вообще —
это записано отдельной задачей `tasks/items/pipeline-step-tests.md`, а не
находка этого прогона.
- Триаж не читает код в поисках дефектов: пропуск любого прохода — мой пропуск
тоже. Пропуск `autotests` на втором круге назван поимённо выше.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке», двумя отдельными списками — они не
сливаются.
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект живёт на единицах записей в
день, и утверждения о росте (в том числе о кардинальности метки) остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма получают
длительность от подставного источника; своего теста у
`adapter/metaviewer/ffmpeg` нет. Решение и его цена —
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Плюс общее, вне зависимости от проекта:** история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще». Последнее
здесь не пустое: панели и алерты, отобранные по `file_extension=".mp3"`,
перестанут пополняться после выкладки, и знает об этом только человек.
### Каких документов проекта не хватило
Строка на каждый, с причиной. Слить нельзя — чинится разным.
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся».** Разметка «инлайн
против развилки» опирается на right-size, а мерки right-size в проекте нет.
Пометки «инлайн» в находках 2 и 3 поставлены по объёму правки (одно слово,
одна строка), и это моё предположение, а не сверка с записанным ориентиром.
- **Конвенции о метриках в `docs/conventions/` нет** — каталог есть, файла нет.
Правила «что можно класть в метку», «кто отвечает за кардинальность», «как
объявляется несовместимая смена формы значения» дома не имеют. Отсюда развилка
вместо однозначного вердикта в находке №1 и вкусовой характер вопроса о доме
записи про `.mp3` → `mp3`.
- **`docs/review.md`, «Типовые ложноположительные» — раздел есть и не пуст**,
четыре пункта; применён пункт про открытый HTTP API (дважды: к находке №1
этого круга и к находке №1 первого). Деградации по нему нет.
- Прочие нужные документы на месте и использованы: инварианты `CLAUDE.md`,
`docs/review.md` целиком, `docs/security.md`, `docs/conventions/logging.md`,
`docs/architecture.md`.
### Сработавшие потолки
- **Ни один проход ни на одном круге не сообщил свой потолок** — ни сколько
находок показал из скольких, ни что осталось за срезом. Это находка о прогоне,
повторившаяся во второй раз: судить, полон ли вход триажа, нечем. «15 находок
на входе второго круга» может означать «15 из 15», а может «15 из скольких-то».
- **Потолок триажа не срабатывал:** 1 из 3 в «Блокирует мердж», 2 из 4 в «Стоит
исправить сейчас». Ничего не выброшено из-за потолка. Выброшенное выброшено по
дедупликации (basics #1 и code #3 — одна причина, дубль умолчания; specs #1
второго круга и моя находка №1 — одна причина, приведение без оракула на точке
употребления) и по отсеву вкусовщины (пункт 7 раздела «Что осталось
непочиненным»).
### Метка `medium`, не `small`
Строка про `small` к этому прогону неприменима: `basics` отработал на глубине
«разбор», дома тем `security`, `operations` и `architecture` открывались на обоих
кругах.
---
## Можно ли мержить
**Кода, который сейчас неверен, я не нашёл ни на одном круге второго прохода.**
Приведение стоит на обеих точках, хвост имени отправителя наружу не выходит,
инвариант приватности из `CLAUDE.md` соблюдён, гейт красный только объявленным
долгом, `openspec validate --strict` — valid.
**Мержить можно после того, как человек закроет развилку №1** — она про то, чем
удержано верное поведение, а не про само поведение. Любой из трёх вариантов
развилки делает состояние мерджабельным; вариант (а) — самый дешёвый и требует
только переформулировать чек-лист `tasks.md` 2а.2, чтобы он не утверждал того,
чего нет.
Находки №2 и №3 — инлайн, вместе это одно слово в двух местах и одна строка в
тесте; мерджу они не мешают, но чинятся дешевле сейчас, чем потом.
Формулировка «критичных проблем не обнаружено» к этому прогону применима **только
вместе с секцией границ покрытия выше**, и главная её строка — `autotests` на
втором круге не запускался, а блокирующую находку нашёл я мутацией, а не проход.
---
---
# Приложение: отчёт первого круга, полностью, как был написан
> Ниже — текст триажа первого круга без правок. Исходы его находок проставлены
> в разделе «Что из починенного починено верно» выше: находка №1 закрыта
> решением контрольной точки (приведение метки), находки №2, №3 и №4 починены и
> удержаны мутациями.
## Сводка
- **Change:** `no-user-filename-in-log`. База диффа `origin/master`, судится
рабочее дерево (`git diff`): 4 файла, +172/15.
- **Размер:** среднее. **Сложность:** знакомое. **Метка: medium** — максимум по
осям. Разметчик сам назвал спорным неприменение проектного триггера
«изменение, трогающее оба входа сразу»: правка лежит в единой точке
`createTranscribeJob`, а не отдельной работой по каждому входу. Человек на
чекпоинте согласился оставить `medium`.
- **Режим прогона:** по графу. Состав ревью кода: `autotests`, `specs`, `code`,
`basics`, `triage`.
- **Сигнал о заниженной метке:** `review-code` пришёл и метку заниженной **не**
считает. От `review-basics` сигнала в переданных мне выводах нет — ни «метка
верна», ни «занижена»; отличить «возражений нет» от «не сказано» по молчанию
нельзя, и я этого не делаю.
- **Гейт:** красный **только объявленным долгом**. Проверено мной самим, не со
слов прохода: `go build ./...`, `go vet ./...`, `gofmt -l .`, `go test ./...`
зелёные; `golangci-lint run` даёт ровно 4 знакомых замечания
(`speechkit.go:55`, `main.go:124`, `worker.go:51`, `transcribe.go:395`).
Смещение `394 → 395` внесено самим диффом. Новых красных шагов нет.
- **Находок на входе:** 17 (autotests 0 + 1 promote, specs 4, basics 6, code 6).
**После дедупликации по причине и отсева:** 4 в первых двух секциях, 3 в
гипотезах, 3 кандидата в правила.
### План разметки против исхода
| тема | дом | глубина | закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs/intake/spec.md` + дельта change | разбор | specs | **закрыта**, 4 находки |
| autotests | `CLAUDE.md`, «Гейт» и «Команды» | — | autotests | **закрыта**, 0 находок + 1 promote |
| conventions | `docs/conventions/logging.md` + весь каталог | разбор | code | **закрыта**, 6 находок |
| architecture | `docs/architecture.md`, «Единые точки проекта» + `docs/passport.md` | разбор | basics | **закрыта**, ответ по вопросу темы + 1 находка |
| security | `docs/security.md`, «Куда уходит содержимое записи», «Из чего строятся пути и ключи» | разбор | basics | **закрыта**, 1 находка (главная) |
| operations | `docs/architecture.md`, «Эксплуатация» + `docs/database.md` | разбор | basics | **закрыта**, ответы по вопросам темы |
Тем без дома нет. Тем без отчёта нет. Своих тем проекта сверх ядра нет.
---
## Блокирует мердж
### 1. Дельта-спека письменно узаконивает произвольный текст отправителя, а хвост уходит на анонимный `/metrics`
- **Где:** `openspec/changes/no-user-filename-in-log/specs/intake/spec.md`
(«Расширение, взятое из этого имени, запретом не накрыто»);
`internal/service/transcribe.go:95` и `:143`; `internal/metrics/metrics.go:24`
и `:44`; `main.go:216`.
- **Severity:** critical. Инвариант `CLAUDE.md`: «Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо».
- **Причина одна** на три носителя, поэтому это одна находка, а не три: `ext`
берётся из недоверенного имени дословно. Её нашли три прохода независимо
(`specs` #1, `basics` #1, `code` #1) — приоритет от этого выше, `confidence`
нет: под всеми проходами одна модель.
- **Оракул (построен и прогнан на этом прогоне):**
```
filepath.Ext("запись.тайное-слово") -> ".тайное-слово"
filepath.Ext("Разговор с Петровым 11.08") -> ".08"
filepath.Ext("отчёт.для Ивановой") -> ".для Ивановой"
```
Метка Prometheus отдаётся наружу дословно — программа с тем же выражением и
тем же `HistogramVec`, что в `metrics.go:24`, на `GET /metrics`:
```
transcriber_input_file_size_bytes_count{file_extension=".тайное-слово"} 1
```
Путь до этой строки достроен по коду: `header.Filename`
(`internal/controller/http/transcribe.go:43`) → `CreateJobFromApi` →
`createTranscribeJob` → `ext` (`:95`) → `WithLabelValues(ext)` (`:143`);
`router.GET("/metrics", gin.WrapH(promhttp.Handler()))` (`main.go:216`) стоит
за `sloggin` и `gin.Recovery` и ни за какой проверкой.
- **Чего эта находка НЕ утверждает:** «HTTP API открыт без аутентификации» —
типовое ложноположительное проекта (`docs/review.md`, «Типовые
ложноположительные»), и открытость `/metrics` записана в `docs/security.md`
строкой «Метрики и здоровье». Новое здесь не открытость, а то, что на эту
открытую поверхность попадает значение, которым распоряжается анонимный
отправитель, — и что дельта-спека это разрешает текстом.
- **Почему первое место в ранжировании:** ущерб необратим по букве инварианта
(строка уехала в собранные логи и в хранилище метрик), а канал шире того, что
задача закрыла: журнал читает владелец, `/metrics` — кто угодно из интернета.
Тем же концом это неограниченная кардинальность метрики, и множество значений
метки задаёт анонимный отправитель — этот исход вероятнее утечки осмысленного
слова и вредит эксплуатации.
- **Действие: развилка.**
> Хвост после последней точки в имени отправителя уходит дословно в журнал, в
> метку `file_extension` и в метку `source_format` на анонимный `/metrics`.
> Дельта-спека сейчас пишет это в канон фразой «расширение запретом не
> накрыто». Что делаем до мерджа?
>
> **(а)** Мержим как есть: остаток записан в модель угроз, нормализация уходит
> отдельной задачей. Канон при этом получает разрешающую фразу.
> **(б)** Мержим, сузив формулировку требования (например: в журнал и в метку
> идёт расширение из списка разрешённых, прочее заменяется на `.bin`), и в этой
> же задаче нормализуем **только значение метки метрики**. Имя файла на диске
> не трогается, раскладка `data/files` не меняется, правка локальна.
> **(в)** Нормализуем `ext` целиком в `createTranscribeJob`. Тогда меняется
> формат имени файла на диске — `CLAUDE.md` называет это необратимым и
> требующим отдельного решения человека, то есть возврата на чекпоинт.
### 2. Третий журнальный поток в оракуле ничем не удержан: снятие middleware не роняет ни одной проверки
- **Где:** `internal/controller/http/transcribe_test.go`, `setupTestEnv` и
`TestCreateTranscribeJob_SenderFileNameNotLogged`.
- **Severity:** major. Класс тот же, что проход `code` нашёл для стандартного
`log` и что уже починено; для потока middleware дефект остался.
- **Оракул (мутация на копии дерева, прогнана):** удаление
`router.Use(sloggin.New(logger))` из тестового роутера —
`ok git.vakhrushev.me/av/transcriber/internal/controller/http`, зелено. Для
сравнения, три другие мутации краснеют как заявлено: снятие `log.SetOutput`
роняет `…NotLoggedOnFailure`; имя под ключом `upload` роняет обе проверки
запрета; `Info → Debug` на строке приёма роняет `…JournalTracesRecord`.
- **Почему это важно, а не педантизм:** `design.md` включил middleware в
тестовый роутер ровно затем, чтобы квантор требования («ни одна журнальная
запись приёма») совпал с оракулом. Сегодня совпадение держится ничем: любой,
кто уберёт строку как лишнюю, сузит оракул критического инварианта молча.
Это записанное свойство проекта — `docs/review.md`, «Типовые узлы / Любой
узел»: «проверка способна упасть», и там же журнальная запись 2026-08-11 об
этом же классе.
- **Что удержит:** в перехваченном журнале есть строка middleware, вот она:
`msg="Incoming request" … request.path=/api/audio … response.status=201`.
Достаточно `require.Contains(journal, "Incoming request")` в проверке
успешного пути — рядом с уже стоящим `require.Contains(journal, "Err:")` в
проверке отказа.
- **Действие: инлайн.**
---
## Стоит исправить сейчас
### 3. Модель угроз называет метку `src_ext`, которой не существует
- **Где:** `docs/security.md:200` — «`file_extension` у размера принятой записи и
`src_ext` у длительности конвертации».
- **Оракул:** `internal/metrics/metrics.go:44` — метки
`{"source_format", "target_format", "error"}`. `grep -rn "src_ext"` по коду
отдаёт единственное совпадение: локальную переменную Go в
`internal/service/transcribe.go:194`. Журнальное поле рядом называется
`src_format`. Метки `src_ext` нет ни в одной метрике.
- **Почему сейчас:** эта строка — единственный носитель остатка в будущее.
Задача про нормализацию будет искать по имени метки и не найдёт её.
`file_extension` назван верно, ошибка ровно в одном слове.
- **Действие: инлайн.** Заменить `src_ext` на `source_format`.
### 4. Критерий рубрики «ровно одна запись приёма» оракулом не закрыт
- **Где:** `openspec/changes/no-user-filename-in-log/tasks.md`, критерий «в
буфере ровно одна запись приёма на принятую запись, её уровень `INFO`»;
проверка `TestCreateTranscribeJob_JournalTracesRecord`.
- **Что есть:** уровень теперь привязан к строке самого приёма и мутацией
проверен (`Info → Debug` краснеет). Числа записей не проверяет ничто.
- **Почему сейчас, а не в гипотезы:** это не догадка, а разрыв между отмеченным
как выполненный критерием и оракулом; закрывается одной строкой
`strings.Count(journal, "Creating transcribe job") == 1`. Заодно это сторож на
вторую строку приёма, в которую имя вернётся мимо нынешних проверок.
- **Действие: инлайн.**
---
## Гипотезы без доказательства
- **Запрет `t.Parallel()` держится комментарием.** `log.SetOutput` процессный, и
ничто, кроме текста рядом, не мешает следующей проверке в этом файле стать
параллельной; тогда перехват уедет к соседу. Оракула нет: воспроизвести это
можно только внесением `t.Parallel()`, то есть той самой будущей правкой.
`Confidence: medium`, severity не выше `minor`.
- **Позитивное утверждение `require.Contains(journal, "Err:")` привязано к
расхождению, которое конвенция объявляет подлежащим устранению** (`log.Printf`
в HTTP-транспорте мимо `slog`). Когда расхождение починят, проверка отказа
покраснеет по причине, не связанной с приватностью. Оракула нет — событие в
будущем. `minor`.
- **Кардинальность метки `file_extension` не замерена.** Утверждение «число
временных рядов растёт неограниченно» верно по построению, но роста никто не
мерил, а `docs/review.md` прямо велит не считать неизмеренный рост новой
находкой (строка про «файлы и объекты не удаляются»). Вес — только внутри
развилки №1.
---
## Promote candidates
- **Правило линтера на ключи `file_name`/`filename` в вызовах логгера**
(`sloglint` либо `forbidigo`). Пришло от `autotests`; `design.md` объявил это
non-goal задачи. Претензия на правило проекта, а не на этот код.
- **Свойство в `docs/review.md`, «Типовые узлы / Любой узел»:** каждый
перехваченный в оракуле поток журнала удерживается **своим** положительным
утверждением, иначе оракул сужается молча. Обобщение находки №2 и уже
починенной находки прохода `code`.
- **Нормализация расширения, взятого из имени отправителя** — отдельной задачей
в урожай (список разрешённых расширений, `.bin` для прочего), с явным решением
про имя файла на диске. Исход зависит от развилки №1.
---
## Что из уже починенного починено недостаточно
Проверено мутациями на копии дерева, не со слов.
- **Починено верно:** снятие `log.SetOutput` роняет проверку отказа; имя под
чужим ключом роняет обе проверки запрета; `Info → Debug` роняет проверку
прослеживаемости; утверждения о размере и уровне переписаны так, что
переживают смену обработчика на JSON (`size["=:\s]+%d` и
`level["=:\s]+INFO` разбирают и `size=33`, и `"size":33`); мёртвый
`log.SetFlags` снят; тестовый роутер собирается той же цепочкой, что боевой;
второй дом состава журнальной строки из `docs/conventions/logging.md` убран,
строка «*Расхождение:*» на месте.
- **Недостаточно — находка №2:** middleware в роутер добавлен, но не удержан;
фикс закрыл поток `log` и не закрыл поток, ради которого роутер и меняли.
- **Недостаточно — находка №3:** переписанный остаток в `docs/security.md`
называет несуществующую метку `src_ext`.
- **Недостаточно — находка №4:** критерий «ровно одна запись приёма» отмечен
выполненным, оракула у него нет.
- **Ослаблений не найдено:** ни одна прежняя проверка не стала слабее, `go test
./...` зелёный, новых замечаний линтера нет.
Отдельно подтверждаю утверждения, на которых стоит `design.md`, — проверял сам:
приём из Telegram имени, данного человеком, до сервиса не доводит
(`internal/controller/tg/tg.go:276` берёт `file.FilePath`, выданный Telegram;
`document.FileName` дальше проверки типа в `isAudioDocument` не идёт), а ключ
объекта в Object Storage строится после конвертации и всегда имеет вид
`идентификатор.ogg` (`internal/service/transcribe.go:190`).
---
## Границы покрытия
### План
Шесть тем, у каждой есть дом и глубина, все шесть перечислены в сводке выше с
исходом. Тем без дома нет, тем без отчёта нет. Своих тем проекта сверх ядра нет.
### Проходы
- **Запускались** на метке `medium`, режим «по графу»: `review-autotests`,
`review-specs` (код против спек), `review-code` (техника и конвенции, глубина
разбор), `review-basics` (темы `security`, `operations`, `architecture`,
глубина разбор), `review-triage`.
- **Не запускались:** проходы ревью дизайна (`specs`, `rubric`) — они
отработали до кода, на этапе предложения, и в этот прогон не входят.
- **Чего в конвейере нет вовсе** — четыре строки, которые не принесёт ни один
проход:
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит сверка
документации (`av-dev-docs:healthcheck`), а не ревью.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` —
тоже процессный. Все числа в этом отчёте сняты на этом прогоне, команды
приложены.
3. **Поимённая сверка с руководством по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» не спрашивал
никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости; «не знаю, чего не
знаю» никто не достаёт.
### Чего запущенные проходы не могли проверить в принципе
- Ни один проход не гонял сервис на настоящих данных: `testdata` в проекте нет
по запрету `CLAUDE.md`, реальные ключи Yandex Cloud под запретом, боевую БД и
`data/files` трогать нельзя. Все оракулы — синтетический вход и мутации.
- `autotests` судит прогон и мутации, а не смысл проверок; `specs` судит код
против заказанного поведения и не ищет дефектов вне него; `code` судит технику
и конвенции и не судит требования; `basics` идёт по трём темам и только по их
записанным домам.
- Приём из Telegram своей проверки не получил вовсе (объявлено риском в
`design.md`): правка достаётся ему общим шагом кода, но оракула на него нет.
- Триаж не читает код в поисках дефектов: пропуск любого прохода — мой пропуск
тоже.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке», двумя списками — они не сливаются.
**Не проверит ни один проход:**
- `operations`: поведение SpeechKit и Object Storage под нагрузкой и на границах
— поднять их в тесте нечем;
- `operations`: реальный профиль нагрузки; проект живёт на единицах записей в
день, и утверждения о росте остаются условиями;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма получают
длительность от подставного источника; своего теста у
`adapter/metaviewer/ffmpeg` нет. Решение и его цена —
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Плюс общее, вне зависимости от проекта:** история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся».** Разметка
«инлайн против развилки» опирается на right-size, а мерки right-size в проекте
нет. Пометки «инлайн» в находках 2–4 поставлены по объёму правки (одна-две
строки), и это моё предположение, а не сверка с записанным ориентиром.
- **Дома у вопроса «что можно класть в метку метрики» нет.** `docs/security.md`
описывает открытость `/metrics`, но правила состава меток нет ни в модели
угроз, ни в конвенциях; отсюда развилка вместо однозначного вердикта в находке
№1.
- Прочие нужные документы на месте и использованы: инварианты `CLAUDE.md`,
`docs/review.md` (включая «Типовые ложноположительные» — применён пункт про
открытый HTTP API), `docs/security.md`, `docs/conventions/logging.md`.
Деградации по ним нет.
### Сработавшие потолки
- **Ни один из четырёх проходов не сообщил свой потолок** — ни сколько находок
показал из скольких, ни что осталось за срезом. Это находка о прогоне: судить,
полон ли вход триажа, нечем, и «на входе 17 находок» может означать «17 из
17», а может «17 из скольких-то».
- **Потолок триажа сработал мягко:** 2 из 3 в «Блокирует мердж» и 2 из 4 в
«Стоит исправить сейчас». Ничего не выброшено из-за потолка; выброшенное
выброшено по дедупликации (одна причина на три носителя в находке №1, один
класс на четыре формулировки в находке №2) и по отсеву — снятый `log.SetFlags`
и второй дом в конвенции уже починены, а «изоляция буфера держится не тем, чем
заявлено» после правки сведено к комментарию и уехало в гипотезы.
Формулировка «критичных проблем не обнаружено» к этому прогону неприменима:
критичная проблема обнаружена и стоит развилкой №1.
@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
выходит только приведённым к известному виду — этому отдано третье требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается присутствием собственного имени файла в хранилище:
отдельного поля под него приём не заводит.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
пути журнальной строки приёма и в поле формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
@@ -0,0 +1,89 @@
## 1. Правка журнала приёма
- [x] 1.1 Убрать поле с именем, данным отправителем, из журнальной строки общего
шага заведения задачи (`internal/service/transcribe.go`,
`createTranscribeJob`). Идентификатор файла и путь к нему в хранилище остаются,
уровень строки остаётся `INFO`.
- [x] 1.2 Проверить остаток поиском по **значению**: прогон приёма с маркером в
имени, поиск маркера по всему выводу прогона — ничего не найдено. Поиск по
имени поля `file_name` остатком не считается.
## 2. Проверка
- [x] 2.1 Окружение проверок приёма по HTTP отдаёт журнал в буфер вместо
`io.Discard`. Буфер свой на каждый случай, а цепочка журнала воспроизводит
боевую: окружение зовёт `slog.SetDefault` и собирает роутер тем же набором
middleware, что `main.go`, — иначе два потока из трёх остаются вне оракула.
Вывод прогона от этого не меняется.
- [x] 2.7 У каждого из трёх потоков журнала своё удерживающее утверждение:
снятие потока из окружения роняет проверку, а не проходит молча.
- [x] 2.2 Проверка успешного приёма: имя записи несёт маркер — уникальную
ASCII-строку, которой нет в остальном выводе, — при обычном расширении `.mp3`.
В перехваченном журнале маркера нет.
- [x] 2.3 Проверка отказного приёма: источник метаданных не читает запись, имя
несёт тот же маркер. В перехваченном журнале, включая запись об ошибке,
маркера нет.
- [x] 2.4 Проверка прослеживаемости: в журнале есть идентификатор заведённого
файла, расширение принятой записи и её размер в байтах. Утверждение отбирается
по идентификатору **этого** прогона.
- [x] 2.5 Мутация: вернуть имя в журнальную строку под **другим** ключом —
проверки 2.2 и 2.3 краснеют; снять мутацию — зеленеют.
- [x] 2.6 `go test -race -count=5 ./internal/controller/http/` зелёный.
## 2а. Метка метрики (добавлено чекпоинтом после ревью кода)
- [x] 2а.1 Расширение приводится к закрытому перечню известных форматов прежде,
чем уйти меткой метрики; всё прочее — `other`. Имя файла на диске не трогается.
- [x] 2а.2 Обе метки, несущие расширение, идут через приведение: размер принятой
записи и длительность конвертации. Сырой точки употребления гистограммы в
сервисе не остаётся — приведение живёт внутри обёрток пакета метрик, и обойти
его можно только заведя новую точку.
- [x] 2а.4 Обе обёртки судятся по реестру метрик, а не по чистой функции:
снятие приведения в любой из них роняет проверку.
- [x] 2а.3 Проверка приведения: известное расширение с точкой и без, смена
регистра, умолчание сервиса, хвост имени отправителя, часть даты, пустое.
## 3. Гейт и документы
- [x] 3.1 `task gate` зелёный сверх объявленного долга (4 замечания
`golangci-lint` в существующем коде).
- [x] 3.2 `docs/security.md`: строка «Имя файла, данное отправителем, пишется»
переписана остатком — имя из журнала приёма убрано, хвост после последней
точки продолжает попадать в журнал внутри пути файла в хранилище.
- [x] 3.3 Решить, нужна ли строка в `docs/conventions/logging.md` о том, чем
заменено имя, и либо дописать её, либо назвать причину отказа.
## Критерии приёмки
### Из записи задачи `no-user-filename-in-log`, дословно
- Имени, данного отправителем, нет ни в одной журнальной строке приёма. Оракул —
прогон приёма с записью, чьё имя содержит опознаваемую строку, и `grep` этой
строки по перехваченному журналу: ничего не найдено.
- Идентификатор файла, его расширение и длина в журнале остаются: по ним путь
записи прослеживается. Оракул — тот же перехваченный журнал, `file_id` и
`size` на месте.
### Рубрика ревью дизайна
- Запрещённое значение не появляется ни в одном поле и ни в одном `msg` записи о
приёме, включая ветку отказа. Оракул — прогон успеха и прогон отказа с
маркером в имени, поиск маркера по перехваченному журналу пуст в обоих.
- Оракул перехватывает весь журнальный поток приёма, а не один обработчик.
Оракул — мутация: вернуть имя в обход `slog`, проверка краснеет.
- Проверка способна упасть. Оракул — мутация с **другим** ключом поля роняет
проверку; проверка ищет значение, а не имя ключа.
- Маркер уникален и записан ASCII, поиск идёт по сырому тексту буфера. Оракул —
при возвращённом поле утечка находится, несмотря на экранирование обработчиком.
- Буфер журнала свой на случай, утверждение о полях отбирается по идентификатору
этого прогона. Оракул — `go test -race -count=5 ./internal/controller/http/`
зелёный.
- Запись о приёме не исчезает и не меняет адресата: уровень остаётся `INFO`,
категория `msg` прежняя. Оракул — в буфере ровно одна запись приёма на
принятую запись, её уровень `INFO`.
- Прослеживаемость названа полями поимённо, с единицей у числового: размер — в
байтах. Оракул — критерий приёмки называет те же ключи, что и требование.
- Косвенные носители имени названы поимённо и каждый закрыт либо назван
остатком: текст ошибки — закрыт проверкой 2.3, путь на диске и ключ объекта
строятся из идентификатора и расширения, расширение — остаток строкой в
`docs/security.md`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,454 @@
## Context
Способ решения выбран до этой задачи, и переписывать его здесь незачем:
хранилищем становится PocketBase вместе с файлами и панелью
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](../../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
замер — [research/pocketbase.md](../../../docs/research/pocketbase.md)), очередь
остаётся своей таблицей, но коллекцией той же базы
([ADR-2026-08-11-queue-as-pocketbase-collection](../../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md),
сравнение кандидатов — [research/job-queue.md](../../../docs/research/job-queue.md)).
Ниже — только то, что этими решениями не закрыто.
Сегодня состояние лежит в SQLite через `mattn/go-sqlite3`, запросы строит goqu,
схему двигает goose, файлы лежат плоским каталогом с именами по идентификатору,
а HTTP отдаёт gin. Версия PocketBase — та же, что мерила разведка: 0.39.10.
## Goals / Non-Goals
**Goals:**
- Запись, её метаданные и её файл лежат в одном хранилище и видны владельцу
панелью.
- Захват задачи неделим, у задачи есть предел попыток и состояние «мертва».
- Сборка перестаёт требовать CGO.
- Сервис поднимается на чистом каталоге сам.
**Non-Goals:**
- **Перенос прежних данных.** Не переносим и не пытаемся прочитать: решение
задачи, а не следствие отказа.
- **Вход пользователей.** Провайдер OIDC у коллекции пользователей — задача
`oidc-login`.
- **Закрытие панели снаружи.** Это работа выкладки: адрес панели закрывает
Authelia на обратном прокси.
- **Отказ от холостого опроса.** Три воркера по-прежнему опрашивают базу раз в
секунду; открытый вопрос архитектуры остаётся открытым.
- **Удаление файлов и объектов.** Хранение бессрочно, задача `delete-record`
своя.
- **Своё резервное копирование.** Берём мы встроенное или нет — вопрос не
решён и здесь не решается.
## Decisions
### Приложение поднимает PocketBase само, а не отдаёт ему командную строку
Библиотека умеет запускаться двумя способами: `Start()` отдаёт процесс её
собственному набору команд, а `Bootstrap()` плюс `apis.Serve()` оставляет
управление нам.
Берём второй. Первый забирает разбор флагов себе, и ключ `-c config.toml`,
объявленный в командах проекта, пришлось бы либо ломать, либо подпирать. Мягкая
остановка по сигналу с двумя таймаутами из конфигурации — тоже наша, и отдавать
её чужой команде не за что.
Цена названа: набора команд PocketBase у нас не появляется, а вместе с ним нет и
команды заведения владельца панели. Чем это закрыто — ниже.
*Отвергнуто:* `Start()` с подстройкой флагов — экономит десяток строк и ломает
объявленный контракт запуска.
### Наши два обработчика переезжают на роутер PocketBase, gin уходит
Панель отдаётся тем же портом, что и приложение, — так решено ADR. Значит порт
слушает сервер PocketBase, и второму серверу на том же порту взяться неоткуда.
Наши маршруты — `POST /api/audio`, `GET /api/status/:id`, `GET /health`,
`GET /metrics` — переезжают в его роутер обработчиком на `OnServe`. Столкновения
имён нет: PocketBase занимает `/api/collections`, `/api/files`, `/api/settings`,
`/api/logs`, `/api/backups`, `/api/crons`, `/api/realtime`, `/api/batch` и
`/api/health`, а `/api/audio` и `/api/status/:id` свободны. Публичный контракт
API от этого не меняется — меняется то, кто его обслуживает.
Вместе с gin уходит `samber/slog-gin`, и запросы начинает писать журнал
PocketBase. Проверки приёма по HTTP переписываются под новый обработчик; их
предмет — коды, поля ответа и запрет имени отправителя в журнале — сохраняется
дословно.
*Отвергнуто:* **два сервера на разных портах** — наружу опубликован один порт, и
панель осталась бы недоступной либо потребовала бы второй маршрут на прокси;
ADR решил иначе. **gin общим обработчиком под роутером PocketBase** — маршруты
разбирались бы дважды, а совпадение с чужим путём проявилось бы как молчаливый
перехват.
### Схему заводят миграции PocketBase, каталог `migrations/` уходит
Коллекции `files` и `transcribe_jobs` заводит зарегистрированная миграция на Go;
`apis.Serve` применяет непринятые перед стартом сервера. Инвариант проекта
«миграция, уехавшая на сервер, не переписывается» переносится дословно: файл
шага не правится, изменение — только новым файлом.
Goose, goqu и `mattn/go-sqlite3` уходят из зависимостей вместе с каталогом
`migrations/*.sql` и вшиванием его в бинарник.
### Состав коллекций
`files` — по записи на физическую копию, как и сегодня:
| Поле | Что |
| --- | --- |
| `file` | сам файл; пусто у копии в Object Storage |
| `location` | `local` или `s3` |
| `object_key` | ключ объекта; пусто у местной копии |
| `size` | размер в байтах |
Поле названо `location`, а не `storage`, как сегодня, потому что после перевода
слово `storage` занято дважды: так зовётся capability и так зовут само хранилище.
Третий смысл в поле записи развёл бы `storage.FileRepository` и `entity.StorageS3`
по разным вещам под одним словом, и увидеть это в коде было бы нечем.
`transcribe_jobs` — задача и она же очередь. Поля сегодняшней таблицы переезжают
один в один, кроме трёх мест:
- `is_error` **уходит**. Задача выбывает из выборки состоянием, и способ этот
один: два способа разошлись бы, и молчаливо потерялся бы тот, который забыли
проверить;
- `state` получает значение `dead`;
- прибавляется `attempts` — число попыток.
Идентификаторы записей выдаёт PocketBase — 15 знаков собственного алфавита. Наши
UUID уходят: два источника идентификатора в одной таблице дают два формата
ссылки на одну сущность. Ответ `POST /api/audio` при этом продолжает нести
`job_id` строкой — контракт говорит о поле, а не о длине значения.
**Инвариант «новая колонка правится в четырёх местах» остаётся, но переезжает.**
Мест по-прежнему четыре — отображение задачи в запись и обратно, перечень колонок
захвата и структура, в которую он читает, — и все четыре лежат в одном пакете, а
не в четырёх запросах разных слоёв. Условие, ради которого инвариант писался, при
этом не снято: компилятор видит два места из четырёх, и колонка, забытая в паре
«перечень — структура», приезжает из захвата нулевой, а первый же `Save` пишет
этот ноль поверх сохранённого. Теряется поле **только у задачи, попавшей к
воркеру**, — то есть тише, чем прежде.
### Захват — один запрос с `RETURNING` мимо записей коллекции
```
UPDATE transcribe_jobs
SET acquisition_id = ?, acquire_time = ?, attempts = attempts + 1, updated = ?
WHERE id = (SELECT id FROM transcribe_jobs
WHERE state = ? AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_time < ?)
ORDER BY created LIMIT 1)
RETURNING <колонки>
```
`RETURNING` в движке за `modernc.org/sqlite` есть, и замер разведки показал: на
трёх горутинах разом запись получает ровно одна. Запрос идёт через `app.DB()`,
который всё, кроме выборок, направляет в пул с единственным соединением, — то
есть захваты выстраиваются в очередь, а не соревнуются за файл.
`ORDER BY` идёт по времени заведения **и по ключу записи**: время неуникально, и
без ключа порядок обработки невоспроизводим, а проверка, опирающаяся на
«следующую» задачу, зелена через раз.
**Время во всех колонках очереди — то же, каким хранилище пишет свои
`created`/`updated`:** строка `2006-01-02 15:04:05.000Z` в UTC (`types.DateTime`
библиотеки). Наш запрос кладёт и сравнивает `acquire_time`, `delay_time` и
`updated` только через это же значение. Причина не в аккуратности: сравнение
строк в SQLite побайтовое, и вид, разошедшийся на разделителе или на дробной
части, обращает `acquire_time < ?` в постоянную истину — тогда любая захваченная
задача немедленно достаётся второму воркеру — или в постоянную ложь — тогда
брошенная задача не возвращается никогда. Оба исхода тихие, и тест, который сам
же кладёт время своим кодом, зелен в обоих.
Хуки коллекции на сыром запросе не срабатывают — цена названа в ADR; поле
времени изменения проставляет тот же запрос.
*Отвергнуто:* **захват записями коллекции** — это снова два шага без транзакции,
ровно то, от чего уходим. **Захват в транзакции PocketBase** — даёт то же
свойство дороже: транзакция на каждый холостой опрос, которых 259 200 в сутки.
### Результат пишет только держатель захвата
Неделимость захвата не закрывает всего: захват протухает не только у мёртвого
воркера, но и у живого. Конвертация шестичасовой записи идёт дольше часа по
построению, а срок захвата на конвертацию сегодня — час.
Отсюда две правки. Первая: **сроки захвата привязываются к потолку своего шага**,
и в таблице настроек стоят рядом с ним. Вторая, и она важнее: **запись результата
условна по признаку захвата** — шаг, чей захват за время работы достался другому,
завершается без записи и без ответа отправителю.
Без второго два воркера пишут в одну задачу по очереди: результат первого
затирает результат второго, файл второго остаётся сиротой, распознавание уходит в
Yandex дважды за наши деньги, а отправитель получает два ответа на одну запись.
### Мёртвая задача, попытки и пауза
Три вещи, которые легко свести в одну и нельзя: **счётчик попыток**, **пауза
повтора** и **задержка опроса чужой операции**.
**Счётчик** растёт при каждом захвате и обнуляется, когда шаг завершился без
отказа. Рост при захвате, а не при отказе, — единственное, что засчитывает
попытку задаче, уносящей с собой процесс: до объявления отказа такая задача не
доходит никогда, и по счётчику отказов крутилась бы вечно. Обнуление при успехе
делает то же самое с другой стороны: задача, прошедшая конвейер, попыток не
копит и до предела не добирается.
**Кто переводит в «мертва».** Тот, кто захватил задачу с превышенным счётчиком:
захват её выдаёт, вызывающий видит перебор, ставит `dead`, сообщает отправителю и
возвращает «работы нет». Условие `attempts < предел` прямо в отборе не годится —
задача исчезла бы из выборки, не получив состояния, то есть выбыла бы молча.
**Отправителю сообщается.** Переход в «мертва» идёт тем же путём, что отказ шага:
инвариант «Принятая запись не теряется молча» допускает два исхода — либо задача
пригодна к повтору, либо о неудаче сказано, — и молчаливая смерть не подходит ни
под один.
**`dead` и `failed` — разные приговоры, а не два имени одного.** В `failed`
задачу переводит шаг, рассудивший об этой записи окончательно: файл не
конвертируется, распознавание вернуло ошибку. В `dead` задача уходит без такого
суждения: мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам, и выбирать между двумя ему не приходится.
**Пауза повтора** — функция счётчика, ставится в момент отказа. **Задержка опроса
чужой операции** — число, ставится шагом проверки, и попытку он не тратит, потому
что отработал без отказа. Свести их было бы ошибкой ровно потому, что счётчик на
ожидании обнулён: пауза выродилась бы в своё наименьшее значение, и опрос
SpeechKit участился бы с пяти секунд до одной — вчетверо больше обращений к
платному сервису, а отказ по его лимиту тратит попытки уже по-настоящему.
Числа — в таблицу настроек `docs/database.md`:
| Настройка | Значение | Откуда |
| --- | --- | --- |
| Предел попыток | 5 | — |
| Пауза перед повтором | `2^(попытка−1)` секунд, потолок 5 минут | — |
| Срок захвата, конвертация | 8 часов | потолок записи 6 часов плюс запас |
| Срок захвата, проверка операции | 1 час | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | как сегодня |
| Задержка между проверками операции | 5 секунд | как сегодня |
Из вариантов эти числа не выбирались, и это честнее назвать, чем оправдать:
предел 5 и удвоение паузы — обычное умолчание, а не вывод из замера. Позволительно
потому, что числа обратимы — они живут в одной таблице и правятся строкой, в
отличие от имени ключа конфигурации и раскладки файлов.
### Имя файла в хранилище задаём мы, а не хранилище
Умолчание PocketBase строит имя из имени, данного отправителем: `sample.ogg`
превращается в `sample_uztrv6wvz3.ogg` — так это замерила разведка, и так это
предсказали ADR и модель угроз.
**Умолчание не берём.** Спека `intake` уже нормирует обратное: имя отправителя в
хранилище не попадает, потому что имя файла кончается в журнале, а имя
отправителя в журнал не пишется по инварианту приватности. Имя файла у библиотеки
— обычное поле, и мы ставим в него своё: идентификатор с расширением, как
сегодня.
**Суффикса при этом не появляется**, и это выяснилось прогоном: десять случайных
знаков дописывает не укладка, а тот самый конструктор имени, который мы обходим.
Значит имя в хранилище равно заданному, и защищает ссылку не суффикс, а то, что
имени в журнале нет вовсе.
Изъятие из инварианта — расширение, хвост после последней точки — остаётся ровно
таким, каким объявлено, и не расширяется до полного имени.
### Файл кладётся потоком, а рабочая копия заводится одним способом
Расчётный потолок записи — шесть часов, и в память такая запись не помещается.
Библиотека умеет строить файл из пути на диске и читает его потоком. Значит приём
пишет тело во временный файл, отдаёт его хранилищу и убирает за собой; каталог
временных файлов — общесистемный, не `data/`.
Обратная сторона — та же и упускается легче. **Конвертер и чтение метаданных
принимают путь**, потому что отдают файл внешней программе: `ffmpeg` и `ffprobe`
получают имя аргументом. Хранилище пути наружу не даёт, значит между ними нужна
рабочая копия — и вот её-то и надо завести **одним местом**, а не по месту в
каждом шаге.
Место это — сам репозиторий файлов: он выдаёт рабочую копию и единственный
способ её убрать, а зовёт уборку шаг. Полностью замкнуть уборку на репозиторий —
вызовом шага изнутри — мешает конвертация: ей нужны две копии разом, исходник и
результат, и вложенные вызовы читались бы хуже, чем два `defer` подряд. Цена
названа: норма держится проверкой, а не построением, и проверки на уборку есть у
приёма и у шага конвертации.
*Отвергнуто:* **построение файла из байтов в памяти** — проще на строку и роняет
процесс на первой же длинной записи. **Путь внутрь раскладки хранилища, отданный
`ffmpeg` напрямую** — раскладка библиотеки становится нашим контрактом, а
требование «файл адресуется записью» не выполняется с первого дня и молча.
**Перевод конвертера и `ffprobe` на потоки** — дороже всего и упирается в то, что
длительность из потока `ffprobe` отдаёт не всегда.
### Панель — вход в задачу, а не окно просмотра
Ради правки задачи панель и покупалась: мёртвая задача оживляется сменой
состояния, а не запросом в консоли сервера. Но правка полем в панели идёт мимо
кода, который сегодня чистит служебные поля прошлого состояния, — и владелец,
«вернувший задачу в работу», получил бы задачу с прежним признаком захвата
(захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт от
первого отказа). Он бы об этом не узнал.
Поэтому переход, сделанный в панели, проходит те же правила, что переход из кода:
на правку записи задачи вешается хук, который при смене состояния чистит признак
захвата, время захвата, паузу и число попыток. Единая точка перехода остаётся
одна, и панель ходит через неё.
Схема при этом держит то, что сегодня держит компилятор: ссылка на файл
обязательна, перечень состояний закрыт, число попыток неотрицательно. Задача,
заведённая в панели руками, не должна ронять процесс на разыменовании пустой
ссылки — а сегодня уронила бы, и вместе с воркером ушли бы бот и приём по HTTP.
*Отвергнуто:* **панель только для чтения по этой коллекции** — отнимает ровно то,
ради чего перевод затевался. **Оставить как есть** — перекладывает на владельца
знание о четырёх служебных полях, и первая же ошибка тихо ломает задачу.
### Потолок размера назван числом, потому что чужие умолчания малы
Прогон показал то, что чтением не видно: нулевой потолок у поля файла библиотека
читает не как «без предела», а как своё умолчание в **5 МиБ**, а роутер
хранилища отсекает тело запроса на **32 МиБ** раньше нашего обработчика. Оба
умолчания на два-три порядка меньше расчётной записи в шесть часов: приём
отказывал бы на всём длиннее примерно пяти минут, а уже принятая запись
исчерпывала бы попытки на шаге конвертации — результат в ogg переваливает 5 МиБ
примерно на пятой минуте.
Поэтому потолок задан числом и одним: `entity.MaxRecordSize`, 8 ГиБ, выведено из
шести часов с запасом на видео. Тем же числом ограничено тело запроса приёма.
Заодно снят таймаут чтения — умолчание в пять минут не переживает заливку
шестичасовой записи по медленному каналу, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
### Правила панели стоят на правке запросом, а не на всяком сохранении
Первая редакция вешала их модельным событием, и это оказалось дефектом: событие
не различает, кто пишет, и срабатывало на каждом переходе конвейера. Задержка,
поставленная шагом вместе со сменой состояния, стиралась тем же сохранением —
опрос платного распознавания уходил через секунду вместо десяти, — а число
попыток мёртвой задачи, которое переход хранит намеренно, приходило владельцу
нулём.
Событие правки **запросом** различает источник по построению: конвейер пишет
мимо HTTP-слоя и под него не попадает.
### Поле файла не помечаем защищённым, но ссылка не уезжает в журнал
Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
`GET /api/status/:id`, а не ниже. Правила доступа коллекций при этом остаются
пустыми — то есть перечислить записи может только владелец панели, и подобрать
идентификатор снаружи неоткуда.
**Отсюда следствие, которого не было при плоском каталоге, и оно меняет смысл
изъятия из инварианта приватности.** Изъятие выписано под путь на диске:
`data/files/<uuid>.ogg` читателю журнала бесполезен. После перевода имя файла в
хранилище — это последняя часть ссылки `/api/files/...`, по которой запись
скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.
Поэтому **в журнал идёт расширение собственным полем**, а имя файла — ни в каком
виде. Прослеживаемость от этого не страдает: требование `intake` просит
идентификатор, расширение и размер, и все три остаются. Изъятие остаётся ровно
таким, каким объявлено: расширение, и только оно.
**Отказы обрываются там же.** Отказ чтения из хранилища несёт ключ файла целиком,
отказ выгрузки в Object Storage — полный адрес объекта; обе цепочки `%w` уехали
бы в журнал и собрали бы ссылку не хуже успешного пути. Поэтому наружу идёт свой
текст с идентификатором записи, а чужой не оборачивается.
Цена названа: ссылка на файл, единожды утёкшая, работает без ограничения по
времени. Разграничение доступа целиком — задачи `oidc-login` и
`record-ownership`, и до них периметр таков, каким его описывает модель угроз.
### Ссылку на файл строит панель, а не наш контракт
Потребителя у ссылки внутри сервиса нет: ответ опроса готовности её не несёт,
распознавание берёт содержимое, а панель строит ссылку сама. Поэтому метода
«построй ссылку» в договоре ядра с хранилищем **не заводим** — иначе форма
HTTP-пути протекла бы в доменный контракт, а знать о протоколе хранилищу незачем.
Требование «файл отдаётся ссылкой» при этом остаётся: оно нормирует свойство
хранилища, а проверяется прогоном — запросом за файлом и сверкой длины. Первым
потребителем ссылки станет приложение, и заведёт её себе оно.
### Владелец панели заводится ссылкой при первом запуске
Команды заведения владельца у нас нет — её забрал отказ от чужой командной
строки. Библиотека закрывает это сама: пока владелец не заведён, при старте
сервера она печатает ссылку установки, по которой владелец задаёт себе почту и
пароль. Ссылка идёт в журнал контейнера, а журнал читает владелец сервиса.
Ссылка равносильна паролю от панели, поэтому у неё два ограничения, и оба у
библиотеки уже есть: **тридцать минут жизни** (`NewStaticAuthToken(30*time.Minute)`)
и печать **только пока владельца нет**. Проверено чтением её кода;
подтвердить прогоном — шаг приёмки. Бессрочная ссылка в журнале отдала бы панель
всякому читателю логов навсегда — при инварианте «строки уже уехали в журнал
контейнера» это необратимо.
Ключа конфигурации под пароль не появляется, и это осознанно: секрет, которого в
конфигурации нет, не утекает вместе с ней. Хранилище держит только отпечаток.
*Отвергнуто:* **пароль ключом конфигурации** — заводит в конфигурации самый
чувствительный секрет проекта и ставит его в один ряд с токеном бота, тогда как
хранилище умеет обойтись отпечатком.
### Ключи конфигурации: два пути заменяются одним каталогом
`[database] path` и `[storage] path` уходят: база и файлы съезжаются под один
каталог, и по-другому хранилище не умеет.
**Имя ключа конфигурации проект объявил необратимым**, поэтому решение принял
человек 2026-08-11: **`[storage] data_dir` со значением `data`**. Варианты и цена
каждого:
- `[storage] data_dir`**выбрано**. Ключ назван по назначению, как названы и
сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
этом уже занято capability, но в конфигурации оно значит ровно то же — где
лежат данные;
- `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует второго
необратимого переименования;
- `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
каталог на диске, и секция с таким именем читается как «настройки каталога», а
не «настройки хранилища».
## Risks / Trade-offs
- **Правила доступа коллекций оставлены пустыми, а сама база публикует
`/api/collections/...` и служебные разделы наружу** → пустое правило значит
«только владелец панели», то есть анонимный запрос к записям получает отказ.
Проверяется прогоном на живом сервисе, а не рассуждением, и прогон этот —
отдельный шаг приёмки.
- **Панель висит на публичном порту** → закрывает её Authelia на обратном прокси;
это работа выкладки, и до неё панель открыта всякому, кто знает адрес. Записано
моделью угроз, задачи в беклоге нет намеренно.
- **Захват идёт сырым запросом мимо записей коллекции** → правка состава колонок
очереди перестаёт быть видной компилятору в этом одном месте. Держится тестом
захвата, который читает захваченную задачу целиком.
- **Число попыток растёт при захвате** → задача, которую бросают по независящей от
неё причине (перезапуск сервиса), тратит попытки. Смягчение: счётчик обнуляется
на каждом шаге, завершившемся без отказа, поэтому пять перезапусков подряд
должны прийтись на одну и ту же задачу, чтобы её убить.
- **Задача умирает молча, если сообщение отправителю не дошло** → переход в
«мертва» отвечает тем же путём, что и отказ, и отказ отправки логируется так же.
Гарантии доставки у нас нет ни там, ни там, и этой задачей она не заводится.
- **Проверки приёма по HTTP переписываются целиком** → предмет проверок при этом
не меняется, и расхождение поймает сравнение с прежним списком сценариев спеки
`intake`.
- **Идентификаторы задач меняют формат** → внешняя программа, хранящая прежние
идентификаторы, их не найдёт. Прежних данных нет по решению задачи, поэтому
цена нулевая — но названа, потому что при переносе данных была бы не нулевой.
## Migration Plan
Переноса нет. Сервис поднимается на чистом каталоге данных; момент перехода на
сервере назначает человек, и до него прежний каталог остаётся нетронутым.
Откат — возврат прежнего образа и прежнего каталога `data/`: новый каталог
данных заводится рядом, старого не трогает.
## Open Questions
- **Своё резервное копирование PocketBase** — берём или оставляем серверу;
открытый вопрос архитектуры, этой задачей не закрывается.
- **Отказ от холостого опроса** — 259 200 запросов в сутки посчитаны, цена не
измерена; вопрос остаётся открытым.
@@ -0,0 +1,60 @@
## Why
Записи, их метаданные и сами файлы лежат порознь, и владелец сервиса не видит их
ничем, кроме консоли на сервере: чтобы посмотреть задачу или послушать запись,
он идёт руками в базу и в каталог на диске. Заодно принятая запись держится на
захвате из двух шагов подряд, между которыми задачу может перехватить соседний
воркер, а задача, падающая на каждой попытке, падает вечно и никем не считается.
## What Changes
- Записи, их метаданные и файлы съезжаются в одно хранилище, и владелец получает
панель, где видит задачу строкой, правит её и слушает саму запись.
- **BREAKING** Раскладка файлов на диске меняется: плоского каталога с именами по
идентификатору не остаётся, файл ложится в раскладку хранилища. Момент перехода
назначает человек.
- **BREAKING** Прежние данные не переносятся. Сервис начинает с чистого каталога
и заводит свою схему сам.
- Файл перестаёт отдаваться чтением с диска и отдаётся ссылкой, которую хранилище
строит по записи.
- Захват задачи воркером становится одним неделимым шагом: две задачи одному
состоянию больше не достаются.
- У задачи появляется число попыток. Задача, исчерпавшая их, переходит в
состояние «мертва»: из выборки исчезает, но остаётся видна владельцу и
возвращается в работу снятием состояния.
- Пауза перед повтором нарастает с номером попытки.
- Сборка перестаёт требовать CGO.
- Появляется секрет, которого не было: пароль владельца от панели. В
конфигурации он не лежит.
## Capabilities
### New Capabilities
- `storage`: где живут запись, её метаданные и её файл; как файл попадает в
хранилище и как отдаётся обратно; что владелец видит и правит в панели; с
каким состоянием сервис поднимается на чистом каталоге.
### Modified Capabilities
- `pipeline`: захват задачи становится неделимым; появляются число попыток,
нарастающая пауза и состояние «мертва» вместо признака ошибки, исключающего
задачу навсегда; описывается срок протухания захвата.
- `intake`: принятая запись уезжает в хранилище, а не в плоский каталог;
требование «имя отправителя в хранилище не попадает» остаётся в силе и в новой
раскладке.
## Impact
- Хранилище задач и файлов целиком: прежний слой запросов, построитель запросов и
механизм миграций уходят вместе с каталогом `migrations/`.
- Договор между ядром и хранилищем: интерфейсы репозиториев задач и файлов.
- Состав полей задачи: прибавляется число попыток, признак ошибки уступает место
состоянию в перечне состояний.
- Ключи конфигурации: путь к базе и путь к каталогу файлов заменяются одним
каталогом данных.
- Приём по HTTP и приём из Telegram — в части того, куда кладётся принятая
запись.
- Сборка образа: набор зависимостей меняется, требование CGO уходит.
- Документы: схема хранилища, инварианты и запреты с путями, модель угроз в части
того, из чего строятся пути.

Some files were not shown because too many files have changed in this diff Show More