Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8bb78103a | ||
|
|
4c4a8be07b | ||
|
|
d71aec4ab2 | ||
|
|
0cd105927b | ||
|
|
16e5372f2d | ||
|
|
7d672a7dc8 | ||
|
|
2e30d668ce | ||
|
|
ef61ced2e5 | ||
|
|
5ec9c77dfa | ||
|
|
78f2a8aecc |
No files matched your search
+1
-1
@@ -7,4 +7,4 @@ dist/
|
|||||||
.codex
|
.codex
|
||||||
.qwen/
|
.qwen/
|
||||||
tmp
|
tmp
|
||||||
.scratch/handoffs/
|
/.scratch/
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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 автоматически не переносятся.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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` проверяет его до моделей.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user