Compare commits

..
10 Commits
Author SHA1 Message Date
Dmitry Dementiev b8bb78103a docs(benchmark): зафиксирован stop Ryzen-прогона
- Зачем:
  - задача #46 должна завершиться воспроизводимым решением по каноническому Ryzen-эксперименту.
- Что:
  - записан stop из-за несовпадения числа кластеров свежего W0.
  - сохранены безопасные агрегаты W0 и вторичный провал проверки swap.
  - отмечено, что Sortformer не запускался и данных об ускорении нет.
- Проверка:
  - uv run pytest -q.
  - независимая сверка отчета с закрытым результатом и проверка литературного русского.
2026-09-04 21:38:42 +03:00
Dmitry Dementiev 4c4a8be07b fix(experiment): уточнено определение активной подкачки
- Зачем:
  - занятые, но неизменные swap-страницы ошибочно блокировали канонический Ryzen-прогон.
- Что:
  - active swap определяется изменением счетчиков sin/sout во время ячейки.
  - сохранены memory gates для OOM, RSS plateau, линейного роста и пикового RSS.
- Проверка:
  - uv run pytest -q tests/test_streaming_sortformer_experiment.py tests/test_benchmark_experiment.py tests/test_speaker_diarizer.py tests/test_diarization.py.
2026-09-04 18:50:31 +03:00
Dmitry Dementiev d71aec4ab2 fix(experiment): устранен повторный gate принятия опоры
- Зачем:
  - канонический Ryzen-прогон не должен повторно запрашивать уже данное решение владельца до порога 30%.
- Что:
  - восстановлен короткий контракт approval и удалена лишняя схема owner-package.
  - разрешена парная nullable-опора для диагностик без ослабления обязательных gates.
  - NUL в путях отклоняется обезличенной ошибкой до загрузки моделей.
- Проверка:
  - uv run pytest -q: 457 passed, 2 skipped.
  - обязательный набор эксперимента: 110 passed; целевой Ruff пройден.
  - независимые code и task/taste review закрыты.
2026-09-04 17:48:36 +03:00
Dmitry Dementiev 0cd105927b chore(repo): ветка синхронизирована с origin/master
- Зачем:
  - source commit эксперимента должен включать актуальное состояние master.
- Что:
  - в рабочую ветку влиты изменения конфигурации диаризации и документации.
- Проверка:
  - слияние завершено без конфликтов, рабочее дерево чистое.
2026-09-04 17:21:42 +03:00
Dmitry Dementiev 16e5372f2d chore(repo): новые материалы .scratch исключены из Git
Зачем:
- хранить локальные handoff и launcher в постоянном каталоге без риска случайного коммита
- не терять временно закрепленный стенд диаризации между компьютерами

Что:
- весь корневой .scratch добавлен в ignore
- существующие диагностические скрипты оставлены отслеживаемыми
- описание локальных и переносимых артефактов приведено к новой политике

Проверка:
- git check-ignore -v --no-index .scratch/new-local-artifact
- git ls-files .scratch
- git diff --check
2026-09-04 16:01:39 +03:00
ddmitry 7d672a7dc8 Merge pull request 'feat(config): добавлена настройка диаризации в TOML' (#52) from fix/51-diarize-config into master
Reviewed-on: #52
2026-09-02 14:11:19 +03:00
Dmitriy Dementiev 2e30d668ce refactor(config): уточнены типы каскада настроек
- Зачем:
  - общий union допускал несоответствие между ключом настройки и его типом.
- Что:
  - разделены типы TOML, CLI и полностью разрешённой конфигурации.
  - каскад значений сохранён в типобезопасной generic-функции.
- Проверка:
  - mypy src/local_transcriber/config.py.
  - pytest tests/test_config.py tests/test_cli.py: 109 passed.
2026-09-02 12:52:33 +03:00
Dmitriy Dementiev ef61ced2e5 feat(config): добавлена настройка диаризации в TOML
- Зачем:
  - пользователю нужна постоянная настройка диаризации с явным CLI-переопределением.
- Что:
  - добавлен булев ключ diarize и каскад CLI > TOML > false.
  - добавлены --no-diarize и проверка конфликта с --speakers.
  - обновлены документация и поведенческие тесты single/batch.
- Проверка:
  - uv run pytest tests/test_config.py tests/test_cli.py -v.
  - uv run pytest: 452 passed, 1 skipped, 1 несвязанный Windows-сбой.
2026-09-02 12:45:57 +03:00
ddmitry 5ec9c77dfa Merge pull request 'docs(install): указан Python 3.13 для uv tool' (#50) from codex/docs-uv-python into master
Reviewed-on: #50
2026-09-02 12:16:33 +03:00
Dmitriy Dementiev 78f2a8aecc docs(install): указан Python 3.13 для uv tool
- Зачем:
  - uv tool install сохранял Python 3.11 старого окружения и отклонял пакет с требованием Python 3.13.
- Что:
  - в команды установки и обновления добавлен явный выбор Python 3.13.
  - актуализировано описание выбора Python в спецификации.
- Проверка:
  - git diff --check.
2026-09-02 12:11:57 +03:00
14 changed files with 470 additions and 1347 deletions

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.codex .codex
.qwen/ .qwen/
tmp tmp
.scratch/handoffs/ /.scratch/
+5
View File
@@ -5,6 +5,11 @@
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
проекта, и живут в `.scratch/`, а не в `src/`. проекта, и живут в `.scratch/`, а не в `src/`.
Код этого каталога временно закреплен в Git: его используют несколько задач на
нескольких компьютерах, пока идет исследование диаризации. Общее правило
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
этого стенда продолжают версионироваться.
Результаты первого прогона описаны в Результаты первого прогона описаны в
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md). [разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
+7 -4
View File
@@ -29,7 +29,7 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie
**2. Установить transcriber:** **2. Установить transcriber:**
```bash ```bash
uv tool install git+https://git.dementev.space/ddmitry/local-transcriber.git uv tool install --python 3.13 git+https://git.dementev.space/ddmitry/local-transcriber.git
``` ```
**3. Ускорение (ставится автоматически):** **3. Ускорение (ставится автоматически):**
@@ -63,7 +63,7 @@ transcribe meeting.mp4
**Обновление:** **Обновление:**
```bash ```bash
uv tool install --force git+https://git.dementev.space/ddmitry/local-transcriber.git uv tool install --python 3.13 --force git+https://git.dementev.space/ddmitry/local-transcriber.git
``` ```
**Удаление:** **Удаление:**
@@ -223,8 +223,8 @@ transcribe --uninstall-menu
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
| `--diarize` | — | — | Разделить текст на реплики говорящих | | `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` | | `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод | | `--verbose` | `-v` | — | Подробный вывод |
@@ -282,6 +282,7 @@ device = "openvino-cpu"
model = "large-v3-turbo" model = "large-v3-turbo"
compute_type = "int8" compute_type = "int8"
language = "ru" language = "ru"
diarize = true
``` ```
Порядок поиска: Порядок поиска:
@@ -289,6 +290,8 @@ language = "ru"
2. `~/.config/transcriber/config.toml` 2. `~/.config/transcriber/config.toml`
Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**. Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**.
`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для
отдельного запуска.
При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX. При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX.
Явный `device` из CLI или конфига отключает этот автоматический выбор. Явный `device` из CLI или конфига отключает этот автоматический выбор.
@@ -0,0 +1,73 @@
# Streaming Sortformer: формальный `stop` на Ryzen
**Дата:** 2026-09-04
**Статус:** эксперимент завершен как `stop`. Для трех объектов закрытого
манифеста были закреплены количества кластеров `2 / 2 / 3`, но W0 выдал
`2 / 2 / 2`. Кандидат не запускался.
## Решение
Свежий W0 не прошел обязательную проверку числа говорящих на одном из трех
300-секундных фрагментов. По условиям
[эксперимента](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md)
такая база сравнения непригодна. Выполнена одна из двенадцати запланированных
ячеек. Sortformer и остальные ячейки не запускались, ускорение не считалось.
Данных о скорости или качестве кандидата этот прогон не дал.
Во время W0 также зафиксированы чтение из swap и запись в него. Это независимый
провал проверки среды и памяти, но первой причиной `stop` остается
`cluster-count-mismatch`: проверка качества завершилась раньше итогового снимка
системных счетчиков.
## Результат W0
| Показатель | Значение |
| --- | ---: |
| Суммарный wall, с | `103,242485` |
| Ожидаемые кластеры | `2 / 2 / 3` |
| Полученные кластеры | `2 / 2 / 2` |
| Пиковый RSS | `340 340 736` байт (`324,57` МиБ) |
| Прочитано из swap за ячейку | `503 808` байт (`492` КиБ) |
| Записано в swap за ячейку | `151 552` байт (`148` КиБ) |
Первые два фрагмента прошли обязательные проверки числа кластеров, текста,
порядка слов и интервалов. Третий фрагмент содержал подтвержденные три голоса,
но W0 сформировал два кластера. Повтор не назначался: несовпадение числа
кластеров само по себе завершает шаг приемки W0.
## Условия
W0 выполнялся по закрепленному рецепту `W0-FRESH`:
- `sherpa-onnx/core 1.13.6`, CPU provider;
- Pyannote Segmentation 3.0 FP32 и WeSpeaker ResNet34 LM FP32;
- один процесс, одна сессия, восемь физических ядер,
`intra_op_threads=8`, `inter_op_threads=1`;
- `window_shift_ratio=0.1`, `FastClustering(auto, 0.89)`,
`min_duration_on=0.3`, `min_duration_off=0.5`;
- Windows Balanced, boost mode `2`; Linux cpufreq/governor в WSL2 не
предоставляется.
До измерения успешно завершились обязательные детерминированные тесты, запуск
с `--validate-only` и предварительная проверка C ABI и конфигурации кандидата.
Последняя подтвердила четыре выходных канала, шаг `0,08` с и закрепленную
CPU-конфигурацию, но не обрабатывала аудио и не является проверкой качества
Sortformer.
## Техническая привязка
- коммит исходного кода `local-transcriber`:
`4c4a8be07b3653cede35b0435e6c9ef6cb3fb87d`;
- коммит NeMo-Speech.cpp:
`4f9676226f667d14608487df744f375db87127f8`;
- модель Streaming Sortformer v2 Q8_0:
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`;
- закрытый итоговый файл W0:
`ad32b3fe8a204e2fe6fbb6a13142e22c7e00ce9f0d816c3b0b8889fcab898e89`.
Последний SHA-256 нужен только для проверки неизменности закрытого результата:
он не оценивает диаризацию и не участвует в решении `stop`. Закрытая основа и
результаты сохраняются для следующих исследований. Их повторное использование
должно быть прямо предусмотрено новым экспериментом; вердикт и расписание
задачи #46 автоматически не переносятся.
+6 -6
View File
@@ -37,13 +37,13 @@
Floor берётся сразу 3.13, а не минимально достаточный 3.11: любой подъём версии Floor берётся сразу 3.13, а не минимально достаточный 3.11: любой подъём версии
всё равно требует прогона на всех трёх платформах, и дорого именно тестирование, всё равно требует прогона на всех трёх платформах, и дорого именно тестирование,
а не строка в `pyproject.toml`. Один переезд вместо двух. Проект ставится там, а не строка в `pyproject.toml`. Один переезд вместо двух. Проект ставится там,
где доступны uv и PyPI, поэтому uv сам поднимет нужный интерпретатор; закрытых где доступны uv и PyPI; команда установки явно запрашивает Python 3.13, поэтому
контуров и оффлайн-зеркал среди пользователей нет. uv найдёт или скачает нужный интерпретатор. Закрытых контуров и оффлайн-зеркал
среди пользователей нет.
Рабочая версия фиксируется файлом `.python-version` (сейчас его в репозитории Рабочая версия фиксируется файлом `.python-version`. Floor разрешает любой
нет). Floor разрешает любой интерпретатор от 3.13 и выше, а пин делает окружение интерпретатор от 3.13 и выше, а пин делает окружение одинаковым на разных
одинаковым на разных машинах — иначе расхождение вылезает именно тогда, когда машинах — иначе расхождение вылезает именно тогда, когда что-то отлаживаешь.
что-то отлаживаешь.
Колёса cp313 проверены для всех нативных зависимостей и всех трёх целевых Колёса cp313 проверены для всех нативных зависимостей и всех трёх целевых
платформ — Windows, Linux, macOS: `openvino-genai` 2026.0.0.0, `ctranslate2` платформ — Windows, Linux, macOS: `openvino-genai` 2026.0.0.0, `ctranslate2`
+6 -4
View File
@@ -23,7 +23,7 @@
## Не входит ## Не входит
- Автоматическое включение диаризации без флага и `--no-diarize`. - Аппаратно-зависимое автоматическое включение диаризации.
- Параллельный запуск ASR и диаризации. - Параллельный запуск ASR и диаризации.
- Сопоставление `Speaker N` с именами участников. - Сопоставление `Speaker N` с именами участников.
- Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические - Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические
@@ -33,10 +33,12 @@
## Пользовательский интерфейс ## Пользовательский интерфейс
- `--diarize` включает диаризацию. По умолчанию она выключена; ключ в - `--diarize` включает диаризацию, `--no-diarize` явно выключает. Булев ключ
`.transcriber.toml` в первой версии не добавляется. `diarize` в `.transcriber.toml` задаёт значение по умолчанию; без него
диаризация выключена. CLI-флаги имеют приоритет над конфигом.
- `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает - `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает
диаризацию. Без него число кластеров определяется автоматически. диаризацию. Без него число кластеров определяется автоматически. Сочетание
`--speakers N` с `--no-diarize` считается ошибкой использования.
- `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком - `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком
получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек. получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек.
- `--verbose` показывает в консоли прогресс, число кластеров и интервалов, - `--verbose` показывает в консоли прогресс, число кластеров и интервалов,
@@ -151,9 +151,10 @@
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
слуховой проверки. Рабочий каталог стенда нельзя размещать в обычной слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот
`.scratch`: репозиторий игнорирует там только служебный каталог каталог локален и целиком игнорируется, поэтому эксперимент из него не
`.scratch/handoffs/`, не связанный с переносом эксперимента между ноутбуками. переносится между ноутбуками. Временно закрепленные диагностические скрипты уже
отслеживаются Git и не меняют этого правила для новых материалов.
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
локальные пути, распознанный текст, временные интервалы, имена участников и локальные пути, распознанный текст, временные интервалы, имена участников и
@@ -186,20 +186,25 @@ mono/16 kHz float32. Публичные документы получают то
после единственного закрепленного преобразования, а ни один путь, ID или после единственного закрепленного преобразования, а ни один путь, ID или
интервал не попадает в stdout режима `--validate-only`. интервал не попадает в stdout режима `--validate-only`.
### Шаг 2. Создать ASR-sidecar и опорную разметку ### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку
Действующий ASR выполняется один раз для каждой полной записи до измерений. Действующий ASR выполняется один раз для каждой полной записи до измерений.
Sidecar хранит канонические слова с временной привязкой и нормализованный текст; Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
fragment-sidecar получается детерминированным срезом той же версии. Затем fragment-sidecar получается детерминированным срезом той же версии. Затем
человек прослушивает каждый фрагмент целиком, фиксирует ожидаемое число человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число
голосовых кластеров для фрагментов и полных записей и создает опорную разметку, голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента
достаточную для mapped purity и выбора мест прослушивания. Опорная разметка необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо
остается приближенной и не объявляется истиной границ. оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает
места прослушивания; сама разметка остается приближенной и не объявляется
истиной границ.
Критерий завершения: для каждого фрагмента и каждой полной записи есть Критерий завершения: для каждого фрагмента и каждой полной записи есть
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
неизменяемый sidecar и SHA-256; для фрагментов также есть опорная разметка. неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует
Числа старых контролей не копируются. измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона
используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к
источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание
для подсчета голосов не требуется.
### Шаг 3. Пройти пользовательский gate ### Шаг 3. Пройти пользовательский gate
@@ -216,6 +221,10 @@ fragment-sidecar получается детерминированным сре
Критерий завершения: в закрытом manifest есть timestamped decision `approved`. Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
Без него исход `needs-user`; стенд не загружает модели. Без него исход `needs-user`; стенд не загружает модели.
В каноническом Ryzen-прогоне решением `approved` служит датированный handoff
владельца в комментарии к задаче #46. Нового подтверждения до достижения
30%-го порога не требуется.
## Измеряемая величина и A/B ## Измеряемая величина и A/B
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата - Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
@@ -333,9 +342,10 @@ hashes и один pair ID; рассчитаны ускорение, CPU и RSS
- каноническая нумерация меток детерминирована на повторном расчете метрик; - каноническая нумерация меток детерминирована на повторном расчете метрик;
- все inputs, параметры и output artifacts имеют SHA-256. - все inputs, параметры и output artifacts имеют SHA-256.
Purity и число слов без говорящего сравниваются с новым `W0-FRESH`. Ухудшение Purity для фрагментов с опорной разметкой и число слов без говорящего
не дает автоматического `fail`, но обязательно добавляет все затронутые места в сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но
слуховой gate. Старые абсолютные значения не участвуют. обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные
значения не участвуют.
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
@@ -353,7 +363,7 @@ Purity и число слов без говорящего сравниваютс
- каждая итоговая speaker label принадлежит `1..4`; - каждая итоговая speaker label принадлежит `1..4`;
- число и суммарная длительность интервалов по каждому каналу; - число и суммарная длительность интервалов по каждому каналу;
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные - число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
кластеры и mapped purity относительно свежей опорной разметки; кластеры и mapped purity для фрагментов с опорной разметкой;
- wall отдельно для model inference и segment post-processing. - wall отдельно для model inference и segment post-processing.
Любой неверный shape/value/label или эффективная конфигурация, отличная от Любой неверный shape/value/label или эффективная конфигурация, отличная от
@@ -380,17 +390,17 @@ RSS выше 2 ГиБ на машине с 16 ГиБ требует отдель
## Артефакты и приватность ## Артефакты и приватность
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
хранилище размещаются вне рабочей копии local-transcriber. Обычная `.scratch` хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри
внутри репозитория не подходит: `.gitignore` покрывает там только репозитория целиком игнорируется, но остается локальной частью конкретного
`.scratch/handoffs/`. Ни один runtime/model/build artifact или закрытый вход не checkout и не подходит для закрытого переносимого хранилища. Ни один
попадает в Git. runtime/model/build artifact или закрытый вход не попадает в Git.
Новое закрытое хранилище содержит: Новое закрытое хранилище содержит:
- versioned manifest со schema, clean local-transcriber source commit, - versioned manifest со schema, clean local-transcriber source commit,
source/model/runtime/build hashes и расписанием; source/model/runtime/build hashes и расписанием;
- исходные ссылки, подготовленные WAV и recipe преобразования; - исходные ссылки, подготовленные WAV и recipe преобразования;
- ASR-sidecar и опорную разметку; - ASR-sidecar и имеющуюся опорную разметку;
- raw per-fragment results, intervals, word assignments и probability - raw per-fragment results, intervals, word assignments и probability
diagnostics; diagnostics;
- machine/power/runtime guards и stderr/stdout logs; - machine/power/runtime guards и stderr/stdout logs;
@@ -458,8 +468,9 @@ privacy scan. Реализация стенда фиксируется чист
## Критерии завершения эксперимента ## Критерии завершения эксперимента
- [ ] Свежая опора подтверждена владельцем и не наследует старые контрольные - [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы,
значения. доступная разметка и числа голосов связаны по хешам, а старые manifest,
расписание и результаты не наследуются.
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей. - [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и - [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
публичном отчете, а `--validate-only` проверяет его до моделей. публичном отчете, а `--validate-only` проверяет его до моделей.
+24 -9
View File
@@ -11,7 +11,13 @@ import typer
from rich.console import Console from rich.console import Console
from rich.status import Status from rich.status import Status
from .config import apply_device_defaults, load_config, resolve_defaults from .config import (
CliValues,
ResolvedConfig,
apply_device_defaults,
load_config,
resolve_defaults,
)
from .context_menu import install_menu as install_context_menu from .context_menu import install_menu as install_context_menu
from .context_menu import uninstall_menu as uninstall_context_menu from .context_menu import uninstall_menu as uninstall_context_menu
from .diarization import build_speaker_transcript from .diarization import build_speaker_transcript
@@ -261,9 +267,9 @@ def main(
min=0, min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)", help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
), ),
diarize: bool = typer.Option( diarize: bool | None = typer.Option(
False, None,
"--diarize", "--diarize/--no-diarize",
help="Разделить транскрипт на реплики говорящих", help="Разделить транскрипт на реплики говорящих",
), ),
speakers: int | None = typer.Option( speakers: int | None = typer.Option(
@@ -328,15 +334,24 @@ def main(
) )
raise SystemExit(2) raise SystemExit(2)
if diarize is False and speakers is not None:
console.print(
"--no-diarize и --speakers несовместимы.",
style="red bold",
)
raise SystemExit(2)
try: try:
config = load_config() config = load_config()
cli_values = { cli_values: CliValues = {
"model": model, "model": model,
"language": language, "language": language,
"device": device, "device": device,
"compute_type": compute_type, "compute_type": compute_type,
"diarize": diarize,
} }
defaults = resolve_defaults(cli_values, config) defaults = resolve_defaults(cli_values, config)
diarize_enabled = defaults["diarize"] is True or speakers is not None
resolved_device = detect_device(defaults["device"]) resolved_device = detect_device(defaults["device"])
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config) defaults = apply_device_defaults(defaults, resolved_device, cli_values, config)
@@ -363,7 +378,7 @@ def main(
force, force,
ct_explicit, ct_explicit,
cpu_threads=threads, cpu_threads=threads,
diarize=diarize or speakers is not None, diarize=diarize_enabled,
speakers=speakers, speakers=speakers,
) )
else: else:
@@ -374,7 +389,7 @@ def main(
verbose, verbose,
ct_explicit, ct_explicit,
cpu_threads=threads, cpu_threads=threads,
diarize=diarize or speakers is not None, diarize=diarize_enabled,
speakers=speakers, speakers=speakers,
) )
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -408,7 +423,7 @@ def main(
def _run_single( def _run_single(
file: Path, file: Path,
defaults: dict[str, str], defaults: ResolvedConfig,
output: Path | None, output: Path | None,
verbose: bool, verbose: bool,
compute_type_explicit: bool = False, compute_type_explicit: bool = False,
@@ -552,7 +567,7 @@ def _run_single(
def _run_batch( def _run_batch(
files: list[Path], files: list[Path],
defaults: dict[str, str], defaults: ResolvedConfig,
verbose: bool, verbose: bool,
force: bool, force: bool,
compute_type_explicit: bool = False, compute_type_explicit: bool = False,
+88 -22
View File
@@ -3,12 +3,48 @@
import tomllib import tomllib
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import TypedDict, TypeVar, cast
HARDCODED_DEFAULTS: dict[str, str] = { type ConfigValue = str | bool
_T = TypeVar("_T")
class ConfigValues(TypedDict, total=False):
"""Проверенные значения из TOML-конфига."""
model: str
language: str
device: str
compute_type: str
diarize: bool
class CliValues(TypedDict, total=False):
"""Явные значения CLI до разрешения каскада."""
model: str | None
language: str | None
device: str | None
compute_type: str | None
diarize: bool | None
class ResolvedConfig(TypedDict):
"""Полная конфигурация после разрешения каскада."""
model: str
language: str
device: str
compute_type: str
diarize: bool
HARDCODED_DEFAULTS: ResolvedConfig = {
"model": "medium", "model": "medium",
"language": "ru", "language": "ru",
"device": "auto", "device": "auto",
"compute_type": "float32", "compute_type": "float32",
"diarize": False,
} }
DEVICE_DEFAULTS: dict[str, dict[str, str]] = { DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
@@ -46,7 +82,7 @@ def find_config_file() -> Path | None:
return None return None
def load_config(path: Path | None = None) -> dict[str, str]: def load_config(path: Path | None = None) -> ConfigValues:
"""Загружает и валидирует TOML-конфиг. """Загружает и валидирует TOML-конфиг.
Неизвестные ключи вызывают предупреждение (а не ошибку) для forward Неизвестные ключи вызывают предупреждение (а не ошибку) для forward
@@ -70,11 +106,19 @@ def load_config(path: Path | None = None) -> dict[str, str]:
stacklevel=2, stacklevel=2,
) )
result: dict[str, str] = {} result: dict[str, ConfigValue] = {}
for key in _VALID_KEYS: for key in _VALID_KEYS:
if key not in data: if key not in data:
continue continue
value = data[key] value = data[key]
if key == "diarize":
if not isinstance(value, bool):
raise ValueError(
f"Значение 'diarize' в {path} должно быть логическим, "
f"получено {type(value).__name__}"
)
result[key] = value
continue
if not isinstance(value, str): if not isinstance(value, str):
raise ValueError( raise ValueError(
f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}" f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}"
@@ -88,31 +132,53 @@ def load_config(path: Path | None = None) -> dict[str, str]:
raise ValueError(f"Значение 'language' в {path} не может быть пустым") raise ValueError(f"Значение 'language' в {path} не может быть пустым")
result[key] = value result[key] = value
return result return cast(ConfigValues, result)
def resolve_defaults( def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
cli_values: dict[str, str | None], config: dict[str, str]
) -> dict[str, str]:
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты."""
result: dict[str, str] = {} return {
for key in HARDCODED_DEFAULTS: "model": _resolve_value(
cli_val = cli_values.get(key) cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"]
if cli_val is not None: ),
result[key] = cli_val "language": _resolve_value(
elif key in config: cli_values.get("language"),
result[key] = config[key] config.get("language"),
else: HARDCODED_DEFAULTS["language"],
result[key] = HARDCODED_DEFAULTS[key] ),
return result "device": _resolve_value(
cli_values.get("device"),
config.get("device"),
HARDCODED_DEFAULTS["device"],
),
"compute_type": _resolve_value(
cli_values.get("compute_type"),
config.get("compute_type"),
HARDCODED_DEFAULTS["compute_type"],
),
"diarize": _resolve_value(
cli_values.get("diarize"),
config.get("diarize"),
HARDCODED_DEFAULTS["diarize"],
),
}
def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -> _T:
"""Выбирает первое явно заданное значение каскада."""
if cli_value is not None:
return cli_value
if config_value is not None:
return config_value
return default
def apply_device_defaults( def apply_device_defaults(
defaults: dict[str, str], defaults: ResolvedConfig,
resolved_device: str, resolved_device: str,
cli_values: dict[str, str | None], cli_values: CliValues,
config: dict[str, str], config: ConfigValues,
) -> dict[str, str]: ) -> ResolvedConfig:
"""Применяет device-aware дефолты для model и compute_type, """Применяет device-aware дефолты для model и compute_type,
если они не были явно заданы через CLI или конфиг.""" если они не были явно заданы через CLI или конфиг."""
device_defs = DEVICE_DEFAULTS.get(resolved_device, {}) device_defs = DEVICE_DEFAULTS.get(resolved_device, {})
@@ -124,4 +190,4 @@ def apply_device_defaults(
if cli_values.get(key) is None and key not in config: if cli_values.get(key) is None and key not in config:
if key in device_defs: if key in device_defs:
result[key] = device_defs[key] result[key] = device_defs[key]
return result return cast(ResolvedConfig, result)
@@ -5,13 +5,11 @@ from __future__ import annotations
import argparse import argparse
import copy import copy
import ctypes import ctypes
import errno
import hashlib import hashlib
import json import json
import math import math
import os import os
import re import re
import stat
import statistics import statistics
import subprocess import subprocess
import sys import sys
@@ -46,51 +44,10 @@ TOP_LEVEL_KEYS = {
"schedule", "schedule",
"rss_sample_interval_ms", "rss_sample_interval_ms",
} }
FRESH_BASIS_KEYS = {"marker", "owner_package", "approval"} FRESH_BASIS_KEYS = {"marker", "approval"}
OWNER_PACKAGE_LINK_KEYS = {"path", "sha256", "generated_at"} APPROVAL_KEYS = {"decision", "timestamp"}
APPROVAL_LINK_KEYS = {
"path",
"sha256",
"actor",
"status",
"decision",
"timestamp",
}
OWNER_PACKAGE_SCHEMA = "local-transcriber.owner-listening-package.v1"
OWNER_PACKAGE_KEYS = {"schema", "generated_at", "references"}
OWNER_PACKAGE_REFERENCE_KEYS = {
"neutral_alias",
"canonical_fragment_audio_sha256",
"final_reference_sha256",
"full_recording_sha256",
}
REFERENCE_ACCEPTANCE_SCHEMA = "local-transcriber.reference-owner-acceptance.v1"
REFERENCE_ACCEPTANCE_KEYS = {
"schema",
"actor",
"status",
"decision",
"timestamp",
"owner_package_sha256",
"references",
"step3",
}
REFERENCE_ACCEPTANCE_ROW_KEYS = OWNER_PACKAGE_REFERENCE_KEYS | {
"fragment_expected_cluster_count",
"full_recording_expected_cluster_count",
"fragment_review_complete",
"full_recording_count_review_complete",
"reference_accepted",
}
STEP3_KEYS = {
"recording_use_approved",
"inputs_and_counts_approved",
"full_recordings_available",
"final_hearing_gate_committed",
}
SOURCE_KEYS = {"commit"} SOURCE_KEYS = {"commit"}
INPUT_KEYS = { INPUT_KEYS = {
"neutral_alias",
"source_path", "source_path",
"source_sha256", "source_sha256",
"source_size_bytes", "source_size_bytes",
@@ -355,294 +312,45 @@ def build_full_recording_schedule(
return result return result
def _aware_datetime(value: Any) -> datetime: def _resolve(root: Path, value: Any) -> Path:
if not isinstance(value, str):
raise PreflightError("approval-timestamp")
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise PreflightError("approval-timestamp") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise PreflightError("approval-timestamp")
return parsed
def _safe_relative_parts(value: Any) -> tuple[str, ...]:
if not isinstance(value, str) or not value: if not isinstance(value, str) or not value:
raise PreflightError("unsafe-relative-path") raise PreflightError("unsafe-relative-path")
pure = PurePosixPath(value) pure = PurePosixPath(value)
if ( if pure.is_absolute() or ".." in pure.parts or "\\" in value or "\x00" in value:
pure.is_absolute()
or not pure.parts
or ".." in pure.parts
or "\\" in value
or "\x00" in value
):
raise PreflightError("path-escape") raise PreflightError("path-escape")
return pure.parts base = root.resolve()
resolved = (base / Path(value)).resolve()
if not resolved.is_relative_to(base):
def _same_file_state(before: os.stat_result, after: os.stat_result) -> bool: raise PreflightError("path-escape")
return ( return resolved
before.st_dev,
before.st_ino,
before.st_mode,
before.st_nlink,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
) == (
after.st_dev,
after.st_ino,
after.st_mode,
after.st_nlink,
after.st_size,
after.st_mtime_ns,
after.st_ctime_ns,
)
@dataclass(frozen=True)
class _BoundFile:
sha256: str
size_bytes: int
payload: bytes | None
def _read_bound_regular_file(
root: Path, path_value: Any, *, capture_payload: bool
) -> _BoundFile:
"""Читает файл относительно доверенного неизменяемого root dirfd."""
parts = _safe_relative_parts(path_value)
directory_flags = (
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
)
opened_fds: list[int] = []
fd_states: list[tuple[int, os.stat_result]] = []
namespace_entries: list[tuple[int, str, os.stat_result]] = []
try:
directory_fd = os.open(root, directory_flags)
opened_fds.append(directory_fd)
root_state = os.fstat(directory_fd)
if not stat.S_ISDIR(root_state.st_mode):
raise PreflightError("file-binding")
fd_states.append((directory_fd, root_state))
for part in parts[:-1]:
parent_fd = directory_fd
directory_fd = os.open(part, directory_flags, dir_fd=parent_fd)
opened_fds.append(directory_fd)
directory_state = os.fstat(directory_fd)
if not stat.S_ISDIR(directory_state.st_mode):
raise PreflightError("file-binding")
fd_states.append((directory_fd, directory_state))
namespace_entries.append((parent_fd, part, directory_state))
file_fd = os.open(
parts[-1],
os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=directory_fd,
)
opened_fds.append(file_fd)
file_state = os.fstat(file_fd)
if not stat.S_ISREG(file_state.st_mode) or file_state.st_nlink != 1:
raise PreflightError("file-binding")
fd_states.append((file_fd, file_state))
namespace_entries.append((directory_fd, parts[-1], file_state))
digest = hashlib.sha256()
size_bytes = 0
chunks: list[bytes] | None = [] if capture_payload else None
while chunk := os.read(file_fd, 64 * 1024):
digest.update(chunk)
size_bytes += len(chunk)
if chunks is not None:
chunks.append(chunk)
for file_descriptor, original_state in fd_states:
if not _same_file_state(original_state, os.fstat(file_descriptor)):
raise PreflightError("file-binding")
for parent_fd, name, original_state in namespace_entries:
current_state = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if not _same_file_state(original_state, current_state):
raise PreflightError("file-binding")
if size_bytes != file_state.st_size:
raise PreflightError("file-binding")
return _BoundFile(
sha256=digest.hexdigest(),
size_bytes=size_bytes,
payload=b"".join(chunks) if chunks is not None else None,
)
except PreflightError:
raise
except ValueError as exc:
raise PreflightError("path-escape") from exc
except OSError as exc:
reason = (
"path-escape"
if exc.errno in {errno.ELOOP, errno.ENOTDIR}
else "file-binding"
)
raise PreflightError(reason) from exc
finally:
for opened_fd in reversed(opened_fds):
try:
os.close(opened_fd)
except OSError:
pass
def _verify(root: Path, path_value: Any, expected_hash: Any, size: Any | None) -> None: def _verify(root: Path, path_value: Any, expected_hash: Any, size: Any | None) -> None:
path = _resolve(root, path_value)
expected = _sha(expected_hash, "invalid-sha256") expected = _sha(expected_hash, "invalid-sha256")
expected_size = None if size is None else _positive_int(size, "invalid-size") if not path.is_file():
bound = _read_bound_regular_file(root, path_value, capture_payload=False) raise PreflightError("file-unavailable")
if expected_size is not None and bound.size_bytes != expected_size: if size is not None and path.stat().st_size != _positive_int(size, "invalid-size"):
raise PreflightError("size-mismatch") raise PreflightError("size-mismatch")
if bound.sha256 != expected: if file_sha256(path) != expected:
raise PreflightError("hash-mismatch") raise PreflightError("hash-mismatch")
def _strict_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: def _validate_approval(value: Any) -> bool:
value: dict[str, Any] = {} if value is None:
for key, item in pairs:
if key in value:
raise PreflightError("owner-acceptance-record")
value[key] = item
return value
def _reject_json_constant(_value: str) -> None:
raise PreflightError("owner-acceptance-record")
def _bound_json(
root: Path, link: Mapping[str, Any], link_keys: set[str]
) -> Mapping[str, Any]:
value = _exact(link, link_keys, "owner-acceptance-link")
expected_sha256 = _sha(value["sha256"], "owner-acceptance-hash")
bound = _read_bound_regular_file(root, value["path"], capture_payload=True)
if bound.sha256 != expected_sha256:
raise PreflightError("owner-acceptance-hash")
if bound.payload is None:
raise PreflightError("owner-acceptance-record")
try:
loaded = json.loads(
bound.payload,
object_pairs_hook=_strict_json_object,
parse_constant=_reject_json_constant,
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PreflightError("owner-acceptance-record") from exc
if not isinstance(loaded, Mapping):
raise PreflightError("owner-acceptance-record")
return loaded
def _validate_owner_package_row(row: Any) -> None:
value = _exact(row, OWNER_PACKAGE_REFERENCE_KEYS, "owner-package-reference")
alias = value["neutral_alias"]
if (
not isinstance(alias, str)
or re.fullmatch(r"[a-z][a-z0-9-]{0,63}", alias) is None
):
raise PreflightError("owner-package-reference")
for key in OWNER_PACKAGE_REFERENCE_KEYS - {"neutral_alias"}:
_sha(value[key], "owner-package-reference")
def _validate_acceptance_row(row: Any) -> None:
value = _exact(
row,
REFERENCE_ACCEPTANCE_ROW_KEYS,
"owner-acceptance-reference",
)
_validate_owner_package_row(
{key: value[key] for key in OWNER_PACKAGE_REFERENCE_KEYS}
)
for key in (
"fragment_expected_cluster_count",
"full_recording_expected_cluster_count",
):
count = value[key]
if type(count) is not int or not 2 <= count <= 4:
raise PreflightError("owner-acceptance-reference")
for key in (
"fragment_review_complete",
"full_recording_count_review_complete",
"reference_accepted",
):
if value[key] is not True:
raise PreflightError("owner-acceptance-reference")
def _validate_reference_owner_acceptance(
*,
root: Path,
basis: Mapping[str, Any],
expected_package_rows: Sequence[Mapping[str, Any]],
expected_acceptance_rows: Sequence[Mapping[str, Any]],
) -> bool:
try:
package_link = _exact(
basis["owner_package"],
OWNER_PACKAGE_LINK_KEYS,
"owner-package-link",
)
package = _exact(
_bound_json(root, package_link, OWNER_PACKAGE_LINK_KEYS),
OWNER_PACKAGE_KEYS,
"owner-package-schema",
)
if package["schema"] != OWNER_PACKAGE_SCHEMA:
raise PreflightError("owner-package-schema")
package_generated_at = _aware_datetime(package["generated_at"])
if package["generated_at"] != package_link["generated_at"]:
raise PreflightError("owner-package-timestamp")
package_rows = package["references"]
if not isinstance(package_rows, list) or len(package_rows) != 3:
raise PreflightError("owner-package-references")
for row in package_rows:
_validate_owner_package_row(row)
if package_rows != list(expected_package_rows):
raise PreflightError("owner-package-binding")
approval_link = _exact(
basis["approval"], APPROVAL_LINK_KEYS, "owner-acceptance-link"
)
acceptance = _exact(
_bound_json(root, approval_link, APPROVAL_LINK_KEYS),
REFERENCE_ACCEPTANCE_KEYS,
"owner-acceptance-schema",
)
if acceptance["schema"] != REFERENCE_ACCEPTANCE_SCHEMA:
raise PreflightError("owner-acceptance-schema")
for key in ("actor", "status", "decision", "timestamp"):
if acceptance[key] != approval_link[key]:
raise PreflightError("owner-acceptance-link-mismatch")
if (
acceptance["actor"] != "owner"
or acceptance["status"] != "approved"
or acceptance["decision"] != "approved"
):
raise PreflightError("owner-acceptance-decision")
accepted_at = _aware_datetime(acceptance["timestamp"])
if accepted_at <= package_generated_at:
raise PreflightError("owner-acceptance-timestamp")
if (
_sha(acceptance["owner_package_sha256"], "owner-package-binding")
!= package_link["sha256"]
):
raise PreflightError("owner-package-binding")
acceptance_rows = acceptance["references"]
if not isinstance(acceptance_rows, list) or len(acceptance_rows) != 3:
raise PreflightError("owner-acceptance-references")
for row in acceptance_rows:
_validate_acceptance_row(row)
if acceptance_rows != list(expected_acceptance_rows):
raise PreflightError("owner-acceptance-binding")
step3 = _exact(acceptance["step3"], STEP3_KEYS, "owner-acceptance-step3")
if any(step3[key] is not True for key in STEP3_KEYS):
raise PreflightError("owner-acceptance-step3")
except (KeyError, OSError, TypeError, PreflightError):
return False return False
approval = _exact(value, APPROVAL_KEYS, "approval-schema")
if approval["decision"] != "approved":
return False
timestamp = approval["timestamp"]
if not isinstance(timestamp, str):
raise PreflightError("approval-timestamp")
try:
parsed = datetime.fromisoformat(timestamp)
except ValueError as exc:
raise PreflightError("approval-timestamp") from exc
if parsed.tzinfo is None:
raise PreflightError("approval-timestamp")
return True return True
@@ -749,12 +457,10 @@ def validate_manifest(
raise PreflightError("legacy-identity") raise PreflightError("legacy-identity")
raise PreflightError("legacy-root") raise PreflightError("legacy-root")
basis = value["fresh_basis"] basis = _exact(value["fresh_basis"], FRESH_BASIS_KEYS, "fresh-basis-schema")
if not isinstance(basis, Mapping): if basis["marker"] != FRESH_BASIS_MARKER:
raise PreflightError("fresh-basis-schema")
if basis.get("marker") != FRESH_BASIS_MARKER:
raise PreflightError("fresh-basis-marker") raise PreflightError("fresh-basis-marker")
basis_schema_valid = set(basis) == FRESH_BASIS_KEYS approved = _validate_approval(basis["approval"])
source = _exact(value["source"], SOURCE_KEYS, "source-schema") source = _exact(value["source"], SOURCE_KEYS, "source-schema")
source_commit = _commit(source["commit"], "source-commit") source_commit = _commit(source["commit"], "source-commit")
if set(environment) != {"commit", "clean"}: if set(environment) != {"commit", "clean"}:
@@ -780,18 +486,8 @@ def validate_manifest(
raise PreflightError("source-hashes-not-unique") raise PreflightError("source-hashes-not-unique")
fragment_hashes = [] fragment_hashes = []
fragment_sidecars = [] fragment_sidecars = []
package_rows = []
acceptance_rows = []
aliases = []
for item in inputs: for item in inputs:
entry = _exact(item, INPUT_KEYS, "input-schema") entry = _exact(item, INPUT_KEYS, "input-schema")
alias = entry["neutral_alias"]
if (
not isinstance(alias, str)
or re.fullmatch(r"[a-z][a-z0-9-]{0,63}", alias) is None
):
raise PreflightError("neutral-alias")
aliases.append(alias)
if verify_files: if verify_files:
_verify( _verify(
root, root,
@@ -813,53 +509,29 @@ def validate_manifest(
media_hash = _sha(record["sha256"], "record-hash") media_hash = _sha(record["sha256"], "record-hash")
sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash") sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash")
if role == "fragment": if role == "fragment":
_sha(record["reference_sha256"], "reference-hash") reference_path = record["reference_path"]
reference_hash = record["reference_sha256"]
if (reference_path is None) != (reference_hash is None):
raise PreflightError("reference-binding")
if reference_hash is not None:
_sha(reference_hash, "reference-hash")
fragment_hashes.append(media_hash) fragment_hashes.append(media_hash)
fragment_sidecars.append(sidecar_hash) fragment_sidecars.append(sidecar_hash)
if verify_files: if verify_files:
_verify(root, record["path"], media_hash, record["size_bytes"]) _verify(root, record["path"], media_hash, record["size_bytes"])
_verify(root, record["sidecar_path"], sidecar_hash, None) _verify(root, record["sidecar_path"], sidecar_hash, None)
if role == "fragment": if role == "fragment" and reference_hash is not None:
_verify( _verify(
root, root,
record["reference_path"], reference_path,
record["reference_sha256"], reference_hash,
None, None,
) )
package_row = {
"neutral_alias": alias,
"canonical_fragment_audio_sha256": entry["fragment"]["sha256"],
"final_reference_sha256": entry["fragment"]["reference_sha256"],
"full_recording_sha256": entry["full_recording"]["sha256"],
}
package_rows.append(package_row)
acceptance_rows.append(
{
**package_row,
"fragment_expected_cluster_count": entry["fragment"][
"expected_cluster_count"
],
"full_recording_expected_cluster_count": entry["full_recording"][
"expected_cluster_count"
],
"fragment_review_complete": True,
"full_recording_count_review_complete": True,
"reference_accepted": True,
}
)
if len(set(aliases)) != 3:
raise PreflightError("neutral-alias")
_validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars) _validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars)
_positive_int(value["rss_sample_interval_ms"], "rss-sample-interval") _positive_int(value["rss_sample_interval_ms"], "rss-sample-interval")
approved = basis_schema_valid and _validate_reference_owner_acceptance(
root=root,
basis=basis,
expected_package_rows=package_rows,
expected_acceptance_rows=acceptance_rows,
)
return PreflightOutcome( return PreflightOutcome(
state="valid" if approved else "needs-user", state="valid" if approved else "needs-user",
reason=None if approved else "reference-owner-acceptance", reason=None if approved else "owner-approval-required",
fragment_count=3, fragment_count=3,
full_recording_count=3, full_recording_count=3,
source_commit_sha256=hashlib.sha256(source_commit.encode("ascii")).hexdigest(), source_commit_sha256=hashlib.sha256(source_commit.encode("ascii")).hexdigest(),
@@ -1423,8 +1095,6 @@ def memory_gate(
for value in integer_values for value in integer_values
): ):
raise ValueError("memory-value") raise ValueError("memory-value")
if swap_before_bytes > 0 or swap_after_bytes > 0:
return {"state": "stop", "reason": "active-swap", **base}
if stage == "fragment": if stage == "fragment":
if len(rss_after_inputs) != 3: if len(rss_after_inputs) != 3:
return {"state": "stop", "reason": "rss-no-plateau", **base} return {"state": "stop", "reason": "rss-no-plateau", **base}
@@ -1774,13 +1444,10 @@ def _guard_transition_reasons(
def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool: def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool:
if before["swap_used_bytes"] > 0:
return True
if after is None: if after is None:
return False return False
return bool( return bool(
after["swap_used_bytes"] > 0 after["swap_sin_bytes"] != before["swap_sin_bytes"]
or after["swap_sin_bytes"] != before["swap_sin_bytes"]
or after["swap_sout_bytes"] != before["swap_sout_bytes"] or after["swap_sout_bytes"] != before["swap_sout_bytes"]
) )
+72 -3
View File
@@ -519,6 +519,27 @@ def test_cli_rejects_nonpositive_speaker_count(tmp_path):
assert out.exit_code == 2 assert out.exit_code == 2
def test_cli_rejects_no_diarize_with_speakers_before_model_load(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
load_model = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch("local_transcriber.cli.load_model", load_model),
):
out = runner.invoke(
app,
[str(audio), "--no-diarize", "--speakers", "2"],
)
assert out.exit_code == 2
assert "--no-diarize и --speakers несовместимы" in out.output
load_model.assert_not_called()
def test_cli_verbose_passes_on_segment_callback(tmp_path): def test_cli_verbose_passes_on_segment_callback(tmp_path):
audio = tmp_path / "test.mp3" audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake") audio.write_bytes(b"fake")
@@ -917,7 +938,13 @@ def test_cli_batch_two_files(tmp_path):
assert "2 обработано" in out.output assert "2 обработано" in out.output
def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path): @pytest.mark.parametrize(
("config", "cli_args"),
[({}, ["--diarize"]), ({"diarize": True}, [])],
)
def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(
tmp_path, config, cli_args
):
first = tmp_path / "first.mp3" first = tmp_path / "first.mp3"
second = tmp_path / "second.mp3" second = tmp_path / "second.mp3"
first.write_bytes(b"fake") first.write_bytes(b"fake")
@@ -941,7 +968,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path):
) )
with ( with (
patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.load_config", return_value=config),
patch( patch(
"local_transcriber.cli.validate_input_file", "local_transcriber.cli.validate_input_file",
side_effect=lambda path: path, side_effect=lambda path: path,
@@ -958,7 +985,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path):
) as load_diarizer, ) as load_diarizer,
patch("local_transcriber.cli.write_transcript") as write, patch("local_transcriber.cli.write_transcript") as write,
): ):
out = runner.invoke(app, [str(first), str(second), "--diarize"]) out = runner.invoke(app, [str(first), str(second), *cli_args])
assert out.exit_code == 0 assert out.exit_code == 0
load_diarizer.assert_called_once() load_diarizer.assert_called_once()
@@ -1232,6 +1259,48 @@ def test_cli_config_applied(tmp_path):
assert mock_load_model.call_args[0][0] == "tiny" assert mock_load_model.call_args[0][0] == "tiny"
@pytest.mark.parametrize(
("config_value", "cli_args", "expected_calls"),
[
(True, [], 1),
(False, [], 0),
(False, ["--diarize"], 1),
(True, ["--no-diarize"], 0),
],
)
def test_cli_resolves_diarization_priority(
tmp_path, config_value, cli_args, expected_calls
):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(segments=[])
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
load_diarizer = MagicMock()
with (
patch(
"local_transcriber.cli.load_config",
return_value={"diarize": config_value},
),
patch("local_transcriber.cli.validate_input_file", return_value=audio),
patch("local_transcriber.cli.detect_device", return_value="cpu"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.load_speaker_diarizer", load_diarizer),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), *cli_args])
assert out.exit_code == 0
assert load_diarizer.call_count == expected_calls
def test_cli_config_overrides_auto_device(tmp_path): def test_cli_config_overrides_auto_device(tmp_path):
audio = tmp_path / "test.mp3" audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake") audio.write_bytes(b"fake")
+34 -1
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -41,6 +40,23 @@ def test_load_config_valid(tmp_path):
assert result == {"model": "small", "language": "ru"} assert result == {"model": "small", "language": "ru"}
@pytest.mark.parametrize("value", [True, False])
def test_load_config_accepts_diarize_boolean(tmp_path, value):
config = tmp_path / "config.toml"
config.write_text(f"diarize = {str(value).lower()}\n")
assert load_config(config) == {"diarize": value}
@pytest.mark.parametrize("toml_value", ['"true"', "1"])
def test_load_config_rejects_non_boolean_diarize(tmp_path, toml_value):
config = tmp_path / "config.toml"
config.write_text(f"diarize = {toml_value}\n")
with pytest.raises(ValueError, match="должно быть логическим"):
load_config(config)
def test_load_config_malformed(tmp_path): def test_load_config_malformed(tmp_path):
config = tmp_path / "config.toml" config = tmp_path / "config.toml"
config.write_text("this is not valid toml [[[") config.write_text("this is not valid toml [[[")
@@ -100,9 +116,26 @@ def test_resolve_defaults_hardcoded_fallback():
"language": "ru", "language": "ru",
"device": "auto", "device": "auto",
"compute_type": "float32", "compute_type": "float32",
"diarize": False,
} }
@pytest.mark.parametrize(
("cli_value", "config_value", "expected"),
[
(None, True, True),
(None, False, False),
(True, False, True),
(False, True, False),
],
)
def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
cli = {"diarize": cli_value}
config = {"diarize": config_value}
assert resolve_defaults(cli, config)["diarize"] is expected
def test_apply_device_defaults_cuda(): def test_apply_device_defaults_cuda():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"} defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None} cli = {"model": None, "language": None, "device": None, "compute_type": None}
File diff suppressed because it is too large. Load diff