docs(openspec): apply spec review — UTC id, 10m stale sent, no-speech wording, list() contract; decisions log
Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e00d7e182c
commit
75bac1eef9
@ -27,9 +27,9 @@
|
||||
|
||||
| Модуль | Ответственность |
|
||||
|---|---|
|
||||
| `record.ts` | тип `VoiceRecord`, `Status`, `transition(record, event, now)`, `MAX_ATTEMPTS = 5`, `BACKOFF_MS = [30s, 2m, 10m, 30m]`, `SENT_STALE_MS = 5m` |
|
||||
| `record.ts` | тип `VoiceRecord`, `Status`, `transition(record, event, now)`, `MAX_ATTEMPTS = 5`, `BACKOFF_MS = [30s, 2m, 10m, 30m]`, `SENT_STALE_MS = 10m`, `NO_SPEECH = 'no-speech'` |
|
||||
| `schedule.ts` | `due(records, now, deviceId)`, `isStaleSent(record, now)`, `claimable(records, now, deviceId)` |
|
||||
| `note.ts` | `parseRecord(content)`, `renderNote(content, record, text?)`, построчный патч ключей `voice-*` во frontmatter, `initialNote(record)` |
|
||||
| `note.ts` | `parseRecord(fields)`, `readVoiceFields(content)`, `renderNote(content, record, text?)`, построчный патч ключей `voice-*` во frontmatter, `initialNote(record)` |
|
||||
| `naming.ts` | `makeId(date, random)`, `extFromMime`, `audioPath`, `notePath(date, n)` |
|
||||
| `ports.ts` | интерфейсы портов (ниже) |
|
||||
| `capture.ts` | `saveCapture(ports, capture)` |
|
||||
@ -45,8 +45,12 @@ interface RecordingStore {
|
||||
interface NoteStore {
|
||||
exists(path: string): Promise<boolean>;
|
||||
create(path: string, content: string): Promise<void>;
|
||||
// все заметки vault, у которых во frontmatter есть voice-id
|
||||
list(): Promise<{ path: string; content: string }[]>;
|
||||
// Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
|
||||
// КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
|
||||
// содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick,
|
||||
// в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет
|
||||
// состояние внутри process().
|
||||
list(): Promise<{ path: string; frontmatter: Record<string, unknown> }[]>;
|
||||
// атомарное чтение-изменение-запись; false, если заметки нет
|
||||
process(path: string, fn: (content: string) => string): Promise<boolean>;
|
||||
}
|
||||
@ -59,7 +63,7 @@ type TranscribeResult =
|
||||
interface Env { now(): Date; random(): number; deviceId: string; }
|
||||
```
|
||||
|
||||
`Env` объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. `NoteStore.process` в обёртке реализуется через `vault.process`. `list()` — через `metadataCache` по наличию `voice-id`.
|
||||
`Env` объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. `NoteStore.process` в обёртке реализуется через `vault.process`. `list()` — только через `metadataCache.getFileCache(file)?.frontmatter` по наличию `voice-id`; поэтому порт отдаёт frontmatter-объект, а не текст заметки.
|
||||
|
||||
### Frontmatter правит core, одной записью
|
||||
|
||||
@ -67,7 +71,7 @@ interface Env { now(): Date; random(): number; deviceId: string; }
|
||||
|
||||
### Выполнение отправки
|
||||
|
||||
`tick()`: защита от повторного входа (флаг `running` + флаг `rerun`) → `list()` → `parseRecord` → просроченные `sent` своего устройства превращаются во временную ошибку → `due()` → по одной: `process` (статус `sent`, `voice-sent-at`) → `read` аудио → `transcribe` → `process` (`inserted` + текст или `retry`/`failed` + область). Если `process` вернул `false` — запись отменена, идём дальше. Исключение из `transcribe` или `read` трактуется как временная ошибка с его сообщением.
|
||||
`tick()`: защита от повторного входа (флаг `running` + флаг `rerun`) → `list()` → `parseRecord(frontmatter)` → просроченные `sent` своего устройства превращаются во временную ошибку → `due()` → по одной: `process` (внутри функция заново читает `voice-*` из актуального `content` через `readVoiceFields`; если запись уже не в ожидаемом состоянии или не своего устройства — возвращает content без изменений и запись пропускается; иначе статус `sent`, `voice-sent-at`) → `read` аудио → `transcribe` → `process` (`inserted` + текст или `retry`/`failed` + область). Если `process` вернул `false` — запись отменена, идём дальше. Исключение из `transcribe` или `read` трактуется как временная ошибка с его сообщением.
|
||||
|
||||
### Ручные команды
|
||||
|
||||
@ -88,4 +92,24 @@ interface Env { now(): Date; random(): number; deviceId: string; }
|
||||
- **Лаг iCloud.** Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято.
|
||||
- **Самописный патч frontmatter.** Не покрывает многострочные YAML-значения в ключах `voice-*`; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами.
|
||||
- **Потеря при жёстком убийстве процесса во время записи.** Принято до spike периодического сброса.
|
||||
- **`list()` по всему vault.** На больших vault — через `metadataCache`, без чтения файлов, кроме найденных заметок.
|
||||
- **`list()` по всему vault.** Дёшево только при реализации через `metadataCache` (закреплено в контракте порта). Читаются лишь заметки, которые реально обрабатываются, через `process`.
|
||||
- **Отставание `metadataCache`.** После записи кэш обновляется асинхронно, следующий tick может увидеть старый статус. Защита — перепроверка состояния внутри `process` по актуальному содержимому.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Формат: дата — решение. *Обоснование.* **Цена ошибки.** Пометка «пересмотрено» — решение скорректировано человеком на ревью.
|
||||
|
||||
1. 2026-09-27 — Пустой (после обрезки пробелов) транскрипт переводит запись в `failed` с причиной `no-speech`; в заметке `> [!info] В записи не распознано речи`, без слова «ошибка» и без призыва повторить. Аудио остаётся. *Повтор тишины бесполезен; это свойство записи, а не сбой сервиса* (формулировка пересмотрена на ревью). **Если провайдер изредка возвращает пустоту на нормальной записи — пользователь увидит «нет речи» и должен сам запустить «Повторить распознавание»; потери данных нет.**
|
||||
2. 2026-09-27 — Очередь ищет записи по `voice-id` во всём vault, а не только в `voice/inbox/`. *Пользователь может перенести заметку до распознавания.* Пересмотрено: `NoteStore.list()` обязан работать через `metadataCache` без чтения файлов, это записано в контракт порта. **Если обёртка нарушит контракт — тормоза на телефоне на каждом tick; ловится ревью обёртки, в core не проверяемо.**
|
||||
3. 2026-09-27 — Добавлены ключи `voice-mime` (исходный mime с параметрами) и `voice-sent-at` (ISO UTC). *Провайдеру нужен точный mime, а не восстановленный по расширению; зависший `sent` нужно отличать от идущего.* **Лишние поля во frontmatter — косметика; без них — неверный mime у провайдера или вечный `sent`.**
|
||||
4. 2026-09-27 — Порог зависшего `sent` — 10 минут (пересмотрено с 5). Автоматически касается только своих записей, чужие — только через `claimAll`. *Двухминутная запись через агрегатор на плохом канале может идти дольше 5 минут.* **Слишком мал — дубль запроса (текст не портится: вставка идемпотентна); слишком велик — запись после убийства Obsidian ждёт повтора до 10 минут.**
|
||||
5. 2026-09-27 — `claimAll` сохраняет `voice-attempts`, меняет только устройство, статус (в `saved`) и удаляет `voice-next-attempt`/`voice-sent-at`. «Повторить распознавание» работает для `failed` и `retry` и сбрасывает счётчик в 0. *`claimAll` — про «где», а не «заново»; ручной повтор — осознанный перезапуск.* **Если `claimAll` должен был давать 5 свежих попыток — запись с телефона уйдёт в `failed` раньше; исправляется ручным повтором.**
|
||||
6. 2026-09-27 — Неизвестный mime сохраняется с расширением `.bin`, запись не отвергается. *Сохранить аудио важнее красивого расширения; принимать ли формат, решает провайдер.* **Файл `.bin` не проигрывается встроенным плеером Obsidian; аудио при этом не потеряно.**
|
||||
7. 2026-09-27 — При переходе в `inserted` удаляются `voice-next-attempt`, `voice-sent-at`, `voice-error`; `voice-attempts` остаётся. *Итоговая заметка чище; счётчик полезен как история.* **Косметика.**
|
||||
8. 2026-09-27 — Frontmatter правит сам core построчно, только ключи `voice-*`, без YAML-библиотеки; статус и тело меняются одной операцией `process`. *Два вызова (`processFrontMatter` + `process`) не атомарны: обрыв между ними даёт `inserted` без текста.* **Самописный патч может сломаться на экзотическом YAML в ключах `voice-*` — плагин такой не пишет; сохранность чужих ключей закрыта тестами.**
|
||||
9. 2026-09-27 — Golden-сравнение побайтное при фиксированном `Env` (время, случайность, устройство). Нет аудиофикстуры — тест падает с сообщением «положите tests/fixtures/<name>.m4a», а не пропускается. *`*.m4a` в `.gitignore`; молчаливый skip дал бы ложно-зелёный прогон.* **На чистом клоне `npm test` красный, пока не положить фикстуру — осознанно.**
|
||||
10. 2026-09-27 — В `.gitignore` добавлен `.claude/worktrees/`. *Хук guard-main запретил правку `.git/info/exclude` в main; вложенные worktree иначе видны как неотслеживаемые.* **Нулевая.**
|
||||
11. 2026-09-27 — `voice-id` строится по UTC (пересмотрено на ревью, было локальное время); имя заметки в inbox — по локальному времени. Имя аудиофайла следует `voice-id`, т.е. тоже UTC. *Порядок id не зависит от пояса устройства; имя заметки — для человека.* **Имена аудио и заметки одной записи расходятся на смещение пояса — можно спутать при ручном поиске; связь всё равно хранится ссылкой `voice-audio`.**
|
||||
12. 2026-09-27 — `NoteStore.list()` отдаёт frontmatter-объект, а не текст заметки; core перепроверяет состояние внутри `process` по актуальному содержимому и пропускает запись, если она уже не в ожидаемом состоянии или не своего устройства. *Следствие правки 2: без чтения файлов текста нет, а `metadataCache` обновляется асинхронно.* **Без перепроверки — повторная отправка только что отправленной записи.**
|
||||
13. 2026-09-27 — Исключение из `RecordingStore.read` или `Transcriber.transcribe` трактуется как временная ошибка со своим сообщением. *Классифицирует провайдер, но непредвиденный throw не должен ронять очередь.* **Если исключение на деле постоянное (например, аудиофайл удалён) — 5 бесполезных попыток за ~42 минуты, затем `failed`.**
|
||||
14. 2026-09-27 — Причина «нет речи» хранится кодом `voice-error: no-speech`, а не текстом. *Рендер должен отличать её от обычной ошибки без разбора сообщений.* **Нулевая: значение внутреннее.**
|
||||
|
||||
@ -36,15 +36,15 @@
|
||||
|
||||
### Requirement: Отправка и результат
|
||||
|
||||
Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST трактоваться как постоянная ошибка «пустой транскрипт».
|
||||
Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST переводить запись в `failed` с причиной «нет речи»: это свойство записи, а не ошибка сервиса, повтор бесполезен.
|
||||
|
||||
#### Scenario: Успешное распознавание
|
||||
- **WHEN** провайдер вернул текст для записи в `saved`
|
||||
- **THEN** заметка содержит текст и статус `inserted`
|
||||
|
||||
#### Scenario: Пустой транскрипт
|
||||
#### Scenario: В записи нет речи
|
||||
- **WHEN** провайдер вернул успех с текстом из одних пробелов
|
||||
- **THEN** запись в `failed` с причиной «пустой транскрипт»
|
||||
- **THEN** запись в `failed` с причиной «нет речи», в заметке — «В записи не распознано речи»
|
||||
|
||||
#### Scenario: Последовательная обработка
|
||||
- **WHEN** в очереди три готовые к отправке записи
|
||||
@ -72,10 +72,10 @@
|
||||
|
||||
### Requirement: Восстановление после прерванной отправки
|
||||
|
||||
Запись текущего устройства в `sent`, у которой с начала отправки прошло больше 5 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов.
|
||||
Запись текущего устройства в `sent`, у которой с начала отправки прошло больше 10 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов.
|
||||
|
||||
#### Scenario: Obsidian выгружен во время запроса
|
||||
- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:20:00
|
||||
- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:19:00
|
||||
- **THEN** запись учитывается как временная неудача и планируется к повтору
|
||||
|
||||
#### Scenario: Запрос ещё может идти
|
||||
@ -92,7 +92,7 @@
|
||||
|
||||
### Requirement: Команда «Распознать все ожидающие здесь»
|
||||
|
||||
Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 5 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` моложе 5 минут, MUST NOT затрагиваться.
|
||||
Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 10 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` не старше 10 минут, MUST NOT затрагиваться.
|
||||
|
||||
#### Scenario: Мак забирает запись телефона
|
||||
- **WHEN** на маке выполнена команда, а в vault есть запись iPhone в `retry` со следующей попыткой через 20 минут
|
||||
|
||||
@ -8,11 +8,11 @@
|
||||
|
||||
### Requirement: Идентификатор и имена файлов
|
||||
|
||||
Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (локальное время начала записи и 4 случайных шестнадцатеричных символа в нижнем регистре). Аудио MUST сохраняться как `voice/source/<id>.<ext>`, где расширение определяется по mime без параметров: `audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, любой другой → `bin`. Заметка MUST создаваться как `voice/inbox/YYYY-MM-DD HH-mm-ss.md` по локальному времени начала записи; при занятом имени MUST добавляться суффикс ` (2)`, ` (3)` и т.д.
|
||||
Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (время начала записи в UTC и 4 случайных шестнадцатеричных символа в нижнем регистре). Аудио MUST сохраняться как `voice/source/<id>.<ext>`, где расширение определяется по mime без параметров: `audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, любой другой → `bin`. Заметка MUST создаваться как `voice/inbox/YYYY-MM-DD HH-mm-ss.md` по локальному времени начала записи; при занятом имени MUST добавляться суффикс ` (2)`, ` (3)` и т.д.
|
||||
|
||||
#### Scenario: Mime с параметрами
|
||||
- **WHEN** запись в формате `audio/webm;codecs=opus` начата 27.09.2026 в 14:15:03
|
||||
- **THEN** аудио сохранено как `voice/source/20260927-141503-<4 hex>.webm`, заметка — `voice/inbox/2026-09-27 14-15-03.md`
|
||||
- **WHEN** запись в формате `audio/webm;codecs=opus` начата 27.09.2026 в 14:15:03 на устройстве в поясе UTC+3
|
||||
- **THEN** аудио сохранено как `voice/source/20260927-111503-<4 hex>.webm`, заметка — `voice/inbox/2026-09-27 14-15-03.md`
|
||||
|
||||
#### Scenario: Коллизия имени заметки
|
||||
- **WHEN** заметка `voice/inbox/2026-09-27 14-15-03.md` уже существует
|
||||
@ -32,7 +32,7 @@
|
||||
|
||||
### Requirement: Тело заметки до распознавания
|
||||
|
||||
Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:<id>%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`. Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста.
|
||||
Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:<id>%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста.
|
||||
|
||||
#### Scenario: Заметка в ожидании повтора
|
||||
- **WHEN** запись перешла в `retry` после второй неудачи со следующей попыткой в 14:17 по местному времени
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user