python-voiceio 0.8.1__tar.gz → 0.8.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_voiceio-0.8.1/python_voiceio.egg-info → python_voiceio-0.8.3}/PKG-INFO +1 -1
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/pyproject.toml +1 -1
- {python_voiceio-0.8.1 → python_voiceio-0.8.3/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/SOURCES.txt +3 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_audio_quality.py +25 -4
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_ibus_ping.py +6 -0
- python_voiceio-0.8.3/tests/test_tokens.py +107 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_transcriber.py +100 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_vocabulary.py +102 -6
- python_voiceio-0.8.3/voiceio/__init__.py +1 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/app.py +63 -21
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/audit.py +37 -16
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/cli.py +53 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/service.py +3 -0
- python_voiceio-0.8.3/voiceio/tokens.py +138 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/transcriber.py +28 -5
- python_voiceio-0.8.3/voiceio/vocab_stats.py +159 -0
- python_voiceio-0.8.3/voiceio/vocabulary.py +316 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/wizard.py +4 -2
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/worker.py +25 -1
- python_voiceio-0.8.1/voiceio/__init__.py +0 -1
- python_voiceio-0.8.1/voiceio/vocabulary.py +0 -186
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/LICENSE +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/README.md +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/setup.cfg +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_audit.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_cli.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_commands.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_config.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_corrections.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_fallback.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_health.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_hints.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_history.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_llm.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_numbers.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_platform.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_prompt.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_retention.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_robustness.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_streaming.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_tts.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_vad.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_wizard.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/__main__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/backends.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/commands.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/config.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/consent.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/corrections.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/demo.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/feedback.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/health.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hints.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/history.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/llm.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/numbers.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/platform.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/prompt.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/recorder.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/retention.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/streaming.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/vad.py +0 -0
- {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/wordfreq.py +0 -0
|
@@ -42,6 +42,7 @@ tests/test_robustness.py
|
|
|
42
42
|
tests/test_security_hardening.py
|
|
43
43
|
tests/test_snapshots.py
|
|
44
44
|
tests/test_streaming.py
|
|
45
|
+
tests/test_tokens.py
|
|
45
46
|
tests/test_transcriber.py
|
|
46
47
|
tests/test_tts.py
|
|
47
48
|
tests/test_vad.py
|
|
@@ -79,8 +80,10 @@ voiceio/retention.py
|
|
|
79
80
|
voiceio/service.py
|
|
80
81
|
voiceio/snapshots.py
|
|
81
82
|
voiceio/streaming.py
|
|
83
|
+
voiceio/tokens.py
|
|
82
84
|
voiceio/transcriber.py
|
|
83
85
|
voiceio/vad.py
|
|
86
|
+
voiceio/vocab_stats.py
|
|
84
87
|
voiceio/vocabulary.py
|
|
85
88
|
voiceio/wizard.py
|
|
86
89
|
voiceio/wordfreq.py
|
|
@@ -125,10 +125,31 @@ class TestPromptBudget:
|
|
|
125
125
|
output. Oversized bias inputs truncated real transcriptions mid-utterance
|
|
126
126
|
(2026-07-05 regression). These bounds must hold."""
|
|
127
127
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
def test_hotwords_budget_leaves_room_to_transcribe(self):
|
|
129
|
+
"""The bound that actually matters, in the unit that actually matters.
|
|
130
|
+
|
|
131
|
+
The old assertion was `_HOTWORDS_MAX_CHARS <= 700` — but chars are the
|
|
132
|
+
wrong unit: proper nouns tokenize at ~2.6 chars/token, so 600 chars was
|
|
133
|
+
really 238 tokens, already past faster-whisper's 223 cap.
|
|
134
|
+
"""
|
|
135
|
+
from voiceio.app import _HOTWORDS_TOKEN_BUDGET
|
|
136
|
+
from voiceio.tokens import (
|
|
137
|
+
HOTWORDS_TOKEN_CAP,
|
|
138
|
+
MAX_SEQUENCE_TOKENS,
|
|
139
|
+
OUTPUT_RESERVE_TOKENS,
|
|
140
|
+
PROMPT_TOKEN_BUDGET,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Past this, faster-whisper silently discards the tail of the list.
|
|
144
|
+
assert _HOTWORDS_TOKEN_BUDGET <= HOTWORDS_TOKEN_CAP
|
|
145
|
+
# The invariant that actually protects the user: hotwords + prompt must
|
|
146
|
+
# leave enough to transcribe with. Violating it truncated freeze chunks
|
|
147
|
+
# mid-sentence (~37 words of room for a 45-60 word chunk) and, worst
|
|
148
|
+
# case, drove the decode budget to <=0 (ValueError).
|
|
149
|
+
overhead = 4 # sot_prev + sot_sequence
|
|
150
|
+
left = (MAX_SEQUENCE_TOKENS - _HOTWORDS_TOKEN_BUDGET
|
|
151
|
+
- PROMPT_TOKEN_BUDGET - overhead)
|
|
152
|
+
assert left >= OUTPUT_RESERVE_TOKENS
|
|
132
153
|
|
|
133
154
|
def test_prompt_builder_default_budget_small(self):
|
|
134
155
|
from voiceio.prompt import PromptBuilder
|
|
@@ -4,10 +4,16 @@ socket path silently starves the healthy instance)."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import socket
|
|
7
|
+
import sys
|
|
7
8
|
import threading
|
|
8
9
|
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
9
12
|
import voiceio.ibus as ibus_mod
|
|
10
13
|
|
|
14
|
+
# IBus (and AF_UNIX DGRAM + Linux abstract autobind) is Linux-only.
|
|
15
|
+
pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="IBus is Linux-only")
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
def test_no_socket_returns_false(tmp_path, monkeypatch):
|
|
13
19
|
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for exact Whisper token counting and the shared 448-token budget.
|
|
2
|
+
|
|
3
|
+
Every budget here used to be a character count with a guessed chars/token ratio.
|
|
4
|
+
The guess was wrong in the direction that mattered: proper nouns tokenize at
|
|
5
|
+
~2.6 chars/token vs prose's ~4.0, so the old 600-char hotwords cap was really
|
|
6
|
+
238 tokens — past faster-whisper's 223 cap, which then silently dropped terms
|
|
7
|
+
and starved the output budget.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from voiceio.tokens import (
|
|
16
|
+
HOTWORDS_TOKEN_CAP,
|
|
17
|
+
MAX_SEQUENCE_TOKENS,
|
|
18
|
+
OUTPUT_RESERVE_TOKENS,
|
|
19
|
+
PROMPT_TOKEN_BUDGET,
|
|
20
|
+
_tokenizer,
|
|
21
|
+
count_tokens,
|
|
22
|
+
truncate_to_tokens,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Most tests here are self-consistent — they count and assert with the same
|
|
26
|
+
# function, so the estimate fallback is fine. A few assert properties of the
|
|
27
|
+
# REAL tokenizer and are meaningless against a fixed chars/token constant; those
|
|
28
|
+
# need a cached model, which CI runners don't have.
|
|
29
|
+
needs_tokenizer = pytest.mark.skipif(
|
|
30
|
+
_tokenizer("small") is None,
|
|
31
|
+
reason="no cached whisper tokenizer (CI runners have no model cache)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestCountTokens:
|
|
36
|
+
def test_empty(self):
|
|
37
|
+
assert count_tokens("", "small") == 0
|
|
38
|
+
|
|
39
|
+
def test_counts_real_tokens(self):
|
|
40
|
+
n = count_tokens("Grafana, Hetzner, OpenRouter", "small")
|
|
41
|
+
assert 5 < n < 30
|
|
42
|
+
|
|
43
|
+
@needs_tokenizer
|
|
44
|
+
def test_proper_nouns_cost_more_than_prose_per_char(self):
|
|
45
|
+
"""The measured fact the whole redesign rests on.
|
|
46
|
+
|
|
47
|
+
Only meaningful against the real tokenizer: the fallback is a fixed
|
|
48
|
+
chars/token constant, which makes both ratios equal by construction.
|
|
49
|
+
"""
|
|
50
|
+
nouns = "Kalshi, Metaculus, HyperNEAT, Kubernetes, Hetzner, Grafana"
|
|
51
|
+
prose = "the quick brown fox jumps over the lazy dog and then it runs"
|
|
52
|
+
nouns_ratio = len(nouns) / count_tokens(nouns, "small")
|
|
53
|
+
prose_ratio = len(prose) / count_tokens(prose, "small")
|
|
54
|
+
assert nouns_ratio < prose_ratio
|
|
55
|
+
|
|
56
|
+
def test_falls_back_to_estimate_without_tokenizer(self):
|
|
57
|
+
"""No tokenizer must degrade to an estimate, never break dictation."""
|
|
58
|
+
import voiceio.tokens as t
|
|
59
|
+
with patch.dict(t._cache, {"bogus-model": None}, clear=False):
|
|
60
|
+
n = count_tokens("hello world", "bogus-model")
|
|
61
|
+
assert n > 0
|
|
62
|
+
|
|
63
|
+
def test_survives_a_broken_tokenizer(self):
|
|
64
|
+
import voiceio.tokens as t
|
|
65
|
+
|
|
66
|
+
class _Boom:
|
|
67
|
+
def encode(self, _):
|
|
68
|
+
raise RuntimeError("corrupt")
|
|
69
|
+
|
|
70
|
+
with patch.dict(t._cache, {"broken": _Boom()}, clear=False):
|
|
71
|
+
assert count_tokens("hello world", "broken") > 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestTruncateToTokens:
|
|
75
|
+
def test_noop_when_within_budget(self):
|
|
76
|
+
assert truncate_to_tokens("short text", 100, "small") == "short text"
|
|
77
|
+
|
|
78
|
+
def test_trims_to_budget(self):
|
|
79
|
+
text = "word " * 500
|
|
80
|
+
out = truncate_to_tokens(text, 50, "small")
|
|
81
|
+
assert count_tokens(out, "small") <= 50
|
|
82
|
+
assert out # not emptied
|
|
83
|
+
|
|
84
|
+
def test_keeps_the_end(self):
|
|
85
|
+
"""Prompts condition the audio that follows, so the OLDEST context goes."""
|
|
86
|
+
text = "alpha beta gamma delta " * 50 + "FINALWORD"
|
|
87
|
+
out = truncate_to_tokens(text, 20, "small")
|
|
88
|
+
assert "FINALWORD" in out
|
|
89
|
+
|
|
90
|
+
def test_zero_budget_is_empty(self):
|
|
91
|
+
assert truncate_to_tokens("anything", 0, "small") == ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestSharedBudget:
|
|
95
|
+
def test_budgets_leave_room_to_transcribe(self):
|
|
96
|
+
"""hotwords + prompt + output must fit 448, with output usable.
|
|
97
|
+
|
|
98
|
+
Before this, the live config spent 223 (hotwords) + 171 (prompt) and
|
|
99
|
+
left ~50 tokens — about 37 words — to transcribe a freeze chunk holding
|
|
100
|
+
45-60. Worst case it went negative and faster-whisper raised
|
|
101
|
+
"The maximum decoding length must be > 0".
|
|
102
|
+
"""
|
|
103
|
+
from voiceio.app import _HOTWORDS_TOKEN_BUDGET
|
|
104
|
+
|
|
105
|
+
total = _HOTWORDS_TOKEN_BUDGET + PROMPT_TOKEN_BUDGET + OUTPUT_RESERVE_TOKENS
|
|
106
|
+
assert total + 4 <= MAX_SEQUENCE_TOKENS
|
|
107
|
+
assert _HOTWORDS_TOKEN_BUDGET <= HOTWORDS_TOKEN_CAP
|
|
@@ -166,3 +166,103 @@ class TestTimeoutRaises:
|
|
|
166
166
|
with pytest.raises(TranscriptionError):
|
|
167
167
|
t.transcribe(np.zeros(16000, dtype=np.float32))
|
|
168
168
|
assert t.last_segments == []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestWorkerStartTimeout:
|
|
172
|
+
"""A worker that never reports READY must not wedge the daemon.
|
|
173
|
+
|
|
174
|
+
faster-whisper's model load contacts huggingface_hub even for a cached
|
|
175
|
+
model; on a blackholed route that connect hangs indefinitely. An unbounded
|
|
176
|
+
READY handshake then holds the transcribe lock forever and every later
|
|
177
|
+
dictation silently returns "".
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def test_start_raises_instead_of_hanging(self):
|
|
181
|
+
import threading
|
|
182
|
+
from voiceio.transcriber import Transcriber, TranscriptionError
|
|
183
|
+
|
|
184
|
+
mock_proc = MagicMock()
|
|
185
|
+
mock_proc.poll.return_value = None
|
|
186
|
+
mock_proc.stdout.readline.side_effect = lambda: threading.Event().wait()
|
|
187
|
+
|
|
188
|
+
with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc), \
|
|
189
|
+
patch("voiceio.transcriber.WORKER_START_TIMEOUT", 0.1):
|
|
190
|
+
with pytest.raises(TranscriptionError, match="failed to start"):
|
|
191
|
+
Transcriber(ModelConfig())
|
|
192
|
+
|
|
193
|
+
# The hung process is reaped, not leaked.
|
|
194
|
+
assert mock_proc.terminate.called or mock_proc.kill.called
|
|
195
|
+
|
|
196
|
+
def test_start_raises_on_garbage_handshake(self):
|
|
197
|
+
from voiceio.transcriber import Transcriber, TranscriptionError
|
|
198
|
+
|
|
199
|
+
mock_proc = MagicMock()
|
|
200
|
+
mock_proc.poll.return_value = None
|
|
201
|
+
mock_proc.stdout.readline.return_value = "Traceback: boom\n"
|
|
202
|
+
|
|
203
|
+
with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc):
|
|
204
|
+
with pytest.raises(TranscriptionError, match="failed to start"):
|
|
205
|
+
Transcriber(ModelConfig())
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestDecodeTimeoutIsLazy:
|
|
209
|
+
"""The timed-out call fails regardless, so reloading the model inline would
|
|
210
|
+
hold the lock through a multi-second start and starve the final pass."""
|
|
211
|
+
|
|
212
|
+
def test_decode_timeout_does_not_reload_inline(self):
|
|
213
|
+
import numpy as np
|
|
214
|
+
from voiceio.transcriber import Transcriber, TranscriptionError
|
|
215
|
+
|
|
216
|
+
mock_proc = MagicMock()
|
|
217
|
+
mock_proc.stdout.readline.return_value = "READY\n"
|
|
218
|
+
mock_proc.poll.return_value = None
|
|
219
|
+
with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc):
|
|
220
|
+
t = Transcriber(ModelConfig())
|
|
221
|
+
|
|
222
|
+
with patch.object(t, "_read_with_timeout", return_value=None), \
|
|
223
|
+
patch.object(t, "_kill_worker") as kill, \
|
|
224
|
+
patch.object(t, "_ensure_worker") as ensure:
|
|
225
|
+
with pytest.raises(TranscriptionError):
|
|
226
|
+
t.transcribe(np.zeros(16000, dtype=np.float32))
|
|
227
|
+
|
|
228
|
+
assert kill.called
|
|
229
|
+
# Only the entry call — never a second, restarting one.
|
|
230
|
+
assert ensure.call_count == 1
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestWorkerModelLoad:
|
|
234
|
+
"""A cached model must load without touching the network."""
|
|
235
|
+
|
|
236
|
+
def test_prefers_local_cache(self):
|
|
237
|
+
from voiceio import worker
|
|
238
|
+
|
|
239
|
+
with patch("voiceio.worker.WhisperModel") as wm:
|
|
240
|
+
worker.load_model("small", device="auto", compute_type="int8")
|
|
241
|
+
assert wm.call_args.kwargs["local_files_only"] is True
|
|
242
|
+
|
|
243
|
+
def test_falls_back_to_network_when_not_cached(self):
|
|
244
|
+
from voiceio import worker
|
|
245
|
+
|
|
246
|
+
sentinel = object()
|
|
247
|
+
with patch("voiceio.worker.WhisperModel",
|
|
248
|
+
side_effect=[OSError("not cached"), sentinel]) as wm:
|
|
249
|
+
got = worker.load_model("small")
|
|
250
|
+
assert got is sentinel
|
|
251
|
+
assert "local_files_only" not in wm.call_args.kwargs
|
|
252
|
+
|
|
253
|
+
def test_every_whisper_load_goes_through_the_guarded_helper(self):
|
|
254
|
+
"""A raw WhisperModel(...) anywhere else reintroduces the hang: the
|
|
255
|
+
teacher audit and the wizard both load models on this same box."""
|
|
256
|
+
import re
|
|
257
|
+
from pathlib import Path
|
|
258
|
+
|
|
259
|
+
src = Path(__file__).resolve().parent.parent / "voiceio"
|
|
260
|
+
offenders = []
|
|
261
|
+
for py in src.rglob("*.py"):
|
|
262
|
+
for i, line in enumerate(py.read_text().splitlines(), 1):
|
|
263
|
+
if re.search(r"(?<!def )\bWhisperModel\(", line):
|
|
264
|
+
# worker.load_model is the one sanctioned construction site.
|
|
265
|
+
if py.name == "worker.py":
|
|
266
|
+
continue
|
|
267
|
+
offenders.append(f"{py.relative_to(src)}:{i}")
|
|
268
|
+
assert not offenders, f"unguarded WhisperModel loads: {offenders}"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""Tests for vocabulary file loading."""
|
|
1
|
+
"""Tests for vocabulary file loading, ranking and token-budgeted selection."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import os
|
|
5
|
+
import time
|
|
5
6
|
|
|
6
7
|
from voiceio.config import ModelConfig
|
|
7
8
|
from voiceio.vocabulary import VocabularyLoader, add_terms, load_vocabulary
|
|
@@ -41,14 +42,21 @@ class TestLoadVocabulary:
|
|
|
41
42
|
result = load_vocabulary(cfg)
|
|
42
43
|
assert result == ""
|
|
43
44
|
|
|
44
|
-
def
|
|
45
|
+
def test_load_is_untruncated(self, tmp_path):
|
|
46
|
+
"""load_vocabulary returns EVERY term — truncating here was the bug.
|
|
47
|
+
|
|
48
|
+
Budgeting moved to select_terms (token-aware, ranked). The callers that
|
|
49
|
+
need the whole list — postcorrect's LLM, and the mining gate's "is this
|
|
50
|
+
already in your vocabulary?" check — were silently reading a truncated
|
|
51
|
+
view before.
|
|
52
|
+
"""
|
|
45
53
|
vocab_file = tmp_path / "vocabulary.txt"
|
|
46
|
-
# Create a very long vocabulary list
|
|
47
54
|
terms = [f"LongTechnicalTerm{i:04d}" for i in range(100)]
|
|
48
55
|
vocab_file.write_text("\n".join(terms))
|
|
49
56
|
cfg = ModelConfig(vocabulary_file=str(vocab_file))
|
|
50
57
|
result = load_vocabulary(cfg)
|
|
51
|
-
assert len(result)
|
|
58
|
+
assert len(result) > 800
|
|
59
|
+
assert result.count(",") == 99
|
|
52
60
|
|
|
53
61
|
def test_no_config_uses_default_location(self, tmp_path, monkeypatch):
|
|
54
62
|
"""With empty vocabulary_file, checks CONFIG_DIR/vocabulary.txt."""
|
|
@@ -145,13 +153,15 @@ class TestVocabularyLoader:
|
|
|
145
153
|
|
|
146
154
|
import voiceio.vocabulary as vocab_mod
|
|
147
155
|
calls = {"n": 0}
|
|
148
|
-
orig = vocab_mod.
|
|
156
|
+
orig = vocab_mod.load_terms
|
|
149
157
|
|
|
150
158
|
def _counting(model_cfg):
|
|
151
159
|
calls["n"] += 1
|
|
152
160
|
return orig(model_cfg)
|
|
153
161
|
|
|
154
|
-
|
|
162
|
+
# load_terms is the real read now; patching load_vocabulary would pass
|
|
163
|
+
# vacuously since the loader no longer calls it.
|
|
164
|
+
monkeypatch.setattr(vocab_mod, "load_terms", _counting)
|
|
155
165
|
loader.get()
|
|
156
166
|
loader.get()
|
|
157
167
|
assert calls["n"] == 0 # mtime unchanged → no reload
|
|
@@ -160,3 +170,89 @@ class TestVocabularyLoader:
|
|
|
160
170
|
cfg = ModelConfig(vocabulary_file=str(tmp_path / "nope.txt"))
|
|
161
171
|
loader = VocabularyLoader(cfg)
|
|
162
172
|
assert loader.get() == ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestSelectTerms:
|
|
176
|
+
"""Selection is permanent: the hotword budget holds ~40-60 terms and the
|
|
177
|
+
vocabulary only grows, so who gets a slot is the whole game."""
|
|
178
|
+
|
|
179
|
+
def _stats(self, tmp_path, data):
|
|
180
|
+
from voiceio.vocab_stats import VocabStats
|
|
181
|
+
s = VocabStats(tmp_path / "vocab_stats.json")
|
|
182
|
+
s._stats = {k.lower(): v for k, v in data.items()}
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
def test_respects_token_budget(self):
|
|
186
|
+
from voiceio.vocabulary import select_terms
|
|
187
|
+
from voiceio.tokens import count_tokens
|
|
188
|
+
|
|
189
|
+
terms = [f"LongTechnicalTerm{i:04d}" for i in range(100)]
|
|
190
|
+
got = select_terms(terms, token_budget=50, model_name="small")
|
|
191
|
+
assert got # something fits
|
|
192
|
+
assert len(got) < len(terms) # but not everything
|
|
193
|
+
assert count_tokens(", ".join(got), "small") <= 50
|
|
194
|
+
|
|
195
|
+
def test_actually_fills_the_budget(self):
|
|
196
|
+
"""Guards the mistake this replaced: costing terms one at a time and
|
|
197
|
+
summing double-counts the ", " joins, so the budget was only ~half
|
|
198
|
+
spent (19 terms / 64 of 120 tokens on the real vocabulary)."""
|
|
199
|
+
from voiceio.vocabulary import select_terms
|
|
200
|
+
from voiceio.tokens import count_tokens
|
|
201
|
+
|
|
202
|
+
terms = [f"Term{i:04d}" for i in range(200)]
|
|
203
|
+
budget = 100
|
|
204
|
+
got = select_terms(terms, token_budget=budget, model_name="small")
|
|
205
|
+
used = count_tokens(", ".join(got), "small")
|
|
206
|
+
assert used <= budget
|
|
207
|
+
# Within one term's cost of the budget — no silent under-fill.
|
|
208
|
+
assert used >= budget - 8
|
|
209
|
+
# And adding the next-ranked term must genuinely overflow.
|
|
210
|
+
nxt = [t for t in terms if t not in got][0]
|
|
211
|
+
assert count_tokens(", ".join(got + [nxt]), "small") > budget
|
|
212
|
+
|
|
213
|
+
def test_never_emits_a_partial_term(self):
|
|
214
|
+
"""The old code did vocab[:600] — a raw char slice that could cut a term
|
|
215
|
+
in half and feed the decoder a fragment."""
|
|
216
|
+
from voiceio.vocabulary import select_terms
|
|
217
|
+
|
|
218
|
+
terms = ["Kubernetes", "Grafana", "Hetzner", "OpenRouter", "Metaculus"]
|
|
219
|
+
got = select_terms(terms, token_budget=12, model_name="small")
|
|
220
|
+
assert all(t in terms for t in got)
|
|
221
|
+
|
|
222
|
+
def test_ranks_by_usage(self, tmp_path):
|
|
223
|
+
from voiceio.vocabulary import select_terms
|
|
224
|
+
|
|
225
|
+
terms = ["Alpha", "Beta", "Gamma"]
|
|
226
|
+
stats = self._stats(tmp_path, {
|
|
227
|
+
"Gamma": {"hits": 50, "last_seen_ts": time.time()},
|
|
228
|
+
})
|
|
229
|
+
# Budget for roughly one term: the used one must win despite file order.
|
|
230
|
+
got = select_terms(terms, token_budget=6, model_name="small", stats=stats)
|
|
231
|
+
assert got[0] == "Gamma"
|
|
232
|
+
|
|
233
|
+
def test_recency_beats_stale_volume(self, tmp_path):
|
|
234
|
+
"""A term used heavily months ago should lose to one used today."""
|
|
235
|
+
from voiceio.vocabulary import select_terms
|
|
236
|
+
|
|
237
|
+
now = time.time()
|
|
238
|
+
stats = self._stats(tmp_path, {
|
|
239
|
+
"Stale": {"hits": 100, "last_seen_ts": now - 365 * 86400},
|
|
240
|
+
"Fresh": {"hits": 5, "last_seen_ts": now},
|
|
241
|
+
})
|
|
242
|
+
got = select_terms(["Stale", "Fresh"], token_budget=6,
|
|
243
|
+
model_name="small", stats=stats, now=now)
|
|
244
|
+
assert got[0] == "Fresh"
|
|
245
|
+
|
|
246
|
+
def test_cold_start_is_file_order(self):
|
|
247
|
+
"""No stats → degrade to exactly the previous behaviour, never worse."""
|
|
248
|
+
from voiceio.vocabulary import select_terms
|
|
249
|
+
|
|
250
|
+
terms = ["First", "Second", "Third"]
|
|
251
|
+
got = select_terms(terms, token_budget=1000, model_name="small", stats=None)
|
|
252
|
+
assert got == terms
|
|
253
|
+
|
|
254
|
+
def test_empty_and_zero_budget(self):
|
|
255
|
+
from voiceio.vocabulary import select_terms
|
|
256
|
+
|
|
257
|
+
assert select_terms([], token_budget=100, model_name="small") == []
|
|
258
|
+
assert select_terms(["A"], token_budget=0, model_name="small") == []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.3"
|
|
@@ -97,10 +97,20 @@ def _import_graphical_env() -> None:
|
|
|
97
97
|
_HEALTH_CHECK_INTERVAL = 10 # seconds between health checks
|
|
98
98
|
|
|
99
99
|
# Whisper's decoder has a hard 448-token sequence budget shared by hotwords,
|
|
100
|
-
# initial_prompt AND the transcription output. Hotwords + prompt must stay
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
100
|
+
# initial_prompt AND the transcription output. Hotwords + prompt must stay well
|
|
101
|
+
# under half of it or output gets truncated mid-utterance.
|
|
102
|
+
#
|
|
103
|
+
# This is a TOKEN budget, counted for real (voiceio.tokens) — the old 600-char
|
|
104
|
+
# proxy assumed ~4 chars/token, but proper nouns (the whole point of a custom
|
|
105
|
+
# vocabulary) run ~2.6, so "600 chars" was really 238 tokens: already past
|
|
106
|
+
# faster-whisper's own 223-token hotwords cap, which then silently dropped the
|
|
107
|
+
# tail.
|
|
108
|
+
#
|
|
109
|
+
# Derived, not guessed: 448 total - 200 output reserve - 120 prompt - 4 overhead
|
|
110
|
+
# = 124. At ~3.5 tokens/term that is ~34 terms. Fewer terms than the old config
|
|
111
|
+
# pretended to carry, but they are real: the old 223-token list left ~50 tokens
|
|
112
|
+
# to transcribe with and truncated freeze chunks mid-sentence.
|
|
113
|
+
_HOTWORDS_TOKEN_BUDGET = 120
|
|
104
114
|
|
|
105
115
|
|
|
106
116
|
class _State(enum.Enum):
|
|
@@ -309,30 +319,56 @@ class VoiceIO:
|
|
|
309
319
|
self._do_start()
|
|
310
320
|
|
|
311
321
|
def _refresh_hotwords(self) -> None:
|
|
312
|
-
"""Rebuild Whisper hotwords
|
|
322
|
+
"""Rebuild Whisper hotwords: rank by usage, fill the token budget.
|
|
313
323
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
324
|
+
Selection is mandatory and permanent — the budget holds ~40-60 terms and
|
|
325
|
+
the vocabulary only grows — so the slots go to the terms actually being
|
|
326
|
+
dictated (voiceio.vocab_stats), highest-scoring first.
|
|
327
|
+
|
|
328
|
+
Both reads are mtime-cached and `set_hotwords` only fires when the string
|
|
329
|
+
changed, so the steady-state cost here is a couple of `stat` calls. That
|
|
330
|
+
matters: this runs synchronously before `recorder.start()`.
|
|
317
331
|
"""
|
|
318
|
-
|
|
332
|
+
terms = self._vocab_loader.get_selected(token_budget=_HOTWORDS_TOKEN_BUDGET)
|
|
319
333
|
# Only merge RARE correction targets (proper nouns, technical terms):
|
|
320
334
|
# common-word targets ("review", "company") add nothing to decoder bias
|
|
321
335
|
# and hundreds of them blow Whisper's 448-token prompt+output budget,
|
|
322
336
|
# which truncates transcriptions mid-utterance.
|
|
323
|
-
|
|
324
|
-
vocab_terms = [
|
|
325
|
-
t for t in self._corrections.vocabulary_terms()
|
|
326
|
-
if not is_common(t, self.cfg.model.language)
|
|
327
|
-
]
|
|
328
|
-
if vocab_terms:
|
|
329
|
-
extra = ", ".join(vocab_terms)
|
|
330
|
-
vocab = f"{vocab}, {extra}" if vocab else extra
|
|
331
|
-
vocab = vocab[:_HOTWORDS_MAX_CHARS]
|
|
337
|
+
vocab = ", ".join(terms + self._rare_correction_targets())
|
|
332
338
|
if vocab != self._hotwords:
|
|
333
339
|
self._hotwords = vocab
|
|
334
|
-
|
|
335
|
-
|
|
340
|
+
# Set unconditionally: an emptied vocabulary must CLEAR the hotwords,
|
|
341
|
+
# not leave the previous string biasing every future decode.
|
|
342
|
+
self.transcriber.set_hotwords(vocab or None)
|
|
343
|
+
|
|
344
|
+
def _record_vocab_usage(self, text: str) -> None:
|
|
345
|
+
"""Record which vocabulary terms this utterance used (finalize path only).
|
|
346
|
+
|
|
347
|
+
This is the feedback signal that decides who gets the hotword budget next
|
|
348
|
+
time. Deliberately not on the recording-start path, and never fatal — a
|
|
349
|
+
usage counter must not be able to break a dictation.
|
|
350
|
+
"""
|
|
351
|
+
try:
|
|
352
|
+
from voiceio import vocab_stats
|
|
353
|
+
vocab_stats.update_from_text(text, self._vocab_loader.get_all())
|
|
354
|
+
except Exception: # noqa: BLE001
|
|
355
|
+
log.debug("vocab usage update failed", exc_info=True)
|
|
356
|
+
|
|
357
|
+
def _rare_correction_targets(self) -> list[str]:
|
|
358
|
+
"""Correction targets worth biasing, cached against corrections mtime.
|
|
359
|
+
|
|
360
|
+
`is_common` over every target used to be recomputed on each recording
|
|
361
|
+
start; the result only changes when corrections.json does.
|
|
362
|
+
"""
|
|
363
|
+
mtime = getattr(self._corrections, "_mtime", None)
|
|
364
|
+
if getattr(self, "_rare_targets_mtime", object()) != mtime:
|
|
365
|
+
from voiceio.wordfreq import is_common
|
|
366
|
+
self._rare_targets = [
|
|
367
|
+
t for t in self._corrections.vocabulary_terms()
|
|
368
|
+
if not is_common(t, self.cfg.model.language)
|
|
369
|
+
]
|
|
370
|
+
self._rare_targets_mtime = mtime
|
|
371
|
+
return self._rare_targets
|
|
336
372
|
|
|
337
373
|
def _do_start(self) -> None:
|
|
338
374
|
"""Transition to RECORDING."""
|
|
@@ -432,8 +468,13 @@ class VoiceIO:
|
|
|
432
468
|
if self._postcorrect is not None:
|
|
433
469
|
# Freshest context for the finalize pass: this recording's window
|
|
434
470
|
# title (captured at start) and the latest transcripts/vocabulary.
|
|
471
|
+
#
|
|
472
|
+
# The FULL vocabulary, not the hotwords string: the decoder can only
|
|
473
|
+
# be biased with ~40-60 terms, but this is an LLM with no 448-token
|
|
474
|
+
# budget. The long tail that can never fit in hotwords gets its only
|
|
475
|
+
# chance to be corrected here.
|
|
435
476
|
self._postcorrect.set_context(
|
|
436
|
-
vocabulary=self.
|
|
477
|
+
vocabulary=", ".join(self._vocab_loader.get_all()),
|
|
437
478
|
recent=self._prompt_builder.recent(3),
|
|
438
479
|
title=self._context_title,
|
|
439
480
|
)
|
|
@@ -552,6 +593,7 @@ class VoiceIO:
|
|
|
552
593
|
self._play_feedback(final_text)
|
|
553
594
|
stored = self._strip_voice_prefix(final_text)
|
|
554
595
|
self._prompt_builder.add_transcript(stored)
|
|
596
|
+
self._record_vocab_usage(stored)
|
|
555
597
|
latency = dict(session.final_latency)
|
|
556
598
|
# stop-to-commit: what the user actually waits for
|
|
557
599
|
latency["finalize_total"] = round(time.monotonic() - t_final, 3)
|
|
@@ -169,9 +169,11 @@ def _read_metrics(path: Path | None = None) -> list[dict]:
|
|
|
169
169
|
|
|
170
170
|
def _default_teacher():
|
|
171
171
|
"""Lazily build the offline teacher transcriber (distil-large-v3)."""
|
|
172
|
-
from
|
|
172
|
+
from voiceio.worker import load_model
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
# Cache-first: the module docstring's "nothing here uses the network" only
|
|
175
|
+
# holds if the load itself can't reach out and hang (see load_model).
|
|
176
|
+
model = load_model("distil-large-v3")
|
|
175
177
|
|
|
176
178
|
def _transcribe(wav_path: Path) -> str:
|
|
177
179
|
segments, _ = model.transcribe(
|
|
@@ -323,14 +325,42 @@ def run_audit(
|
|
|
323
325
|
return report
|
|
324
326
|
|
|
325
327
|
|
|
328
|
+
def _last_seen_map(terms: list[str]) -> dict[str, float]:
|
|
329
|
+
"""When each term was last dictated.
|
|
330
|
+
|
|
331
|
+
Prefers vocab_stats.json, which the runtime maintains per utterance. Falls
|
|
332
|
+
back to scanning history only when no stats exist yet (fresh install, or a
|
|
333
|
+
daemon that hasn't finalized a dictation since stats landed) — that scan is
|
|
334
|
+
O(entries x terms) over the whole history, which at 6k entries and 300+
|
|
335
|
+
terms is not worth running when the answer is already recorded.
|
|
336
|
+
"""
|
|
337
|
+
from voiceio.vocab_stats import VocabStats
|
|
338
|
+
|
|
339
|
+
stats = VocabStats()
|
|
340
|
+
stats.load()
|
|
341
|
+
if stats.as_dict():
|
|
342
|
+
return {t: float(stats.get(t).get("last_seen_ts", 0.0) or 0.0) for t in terms}
|
|
343
|
+
|
|
344
|
+
from voiceio import history
|
|
345
|
+
|
|
346
|
+
last_seen: dict[str, float] = {}
|
|
347
|
+
for e in history.read(limit=0):
|
|
348
|
+
text = (e.get("text") or "").lower()
|
|
349
|
+
ts = e.get("ts", 0.0)
|
|
350
|
+
for term in terms:
|
|
351
|
+
if term.lower() in text and ts > last_seen.get(term, 0.0):
|
|
352
|
+
last_seen[term] = ts
|
|
353
|
+
return last_seen
|
|
354
|
+
|
|
355
|
+
|
|
326
356
|
def _audit_vocabulary(cfg, teacher_texts, now, report) -> None:
|
|
327
357
|
"""Confirm vocab terms the teacher heard; age out terms unseen for 90 days.
|
|
328
358
|
|
|
329
|
-
Aging moves a term to the bottom of vocabulary.txt rather than deleting it
|
|
330
|
-
|
|
331
|
-
|
|
359
|
+
Aging moves a term to the bottom of vocabulary.txt rather than deleting it.
|
|
360
|
+
File order is now only the tie-break for terms with no usage signal —
|
|
361
|
+
vocabulary.select_terms ranks by vocab_stats first — but keeping stale terms
|
|
362
|
+
at the bottom still means they lose ties, and they rejoin on next use.
|
|
332
363
|
"""
|
|
333
|
-
from voiceio import history
|
|
334
364
|
from voiceio.vocabulary import _read_terms, resolve_vocab_path
|
|
335
365
|
|
|
336
366
|
path = resolve_vocab_path(cfg.model)
|
|
@@ -341,16 +371,7 @@ def _audit_vocabulary(cfg, teacher_texts, now, report) -> None:
|
|
|
341
371
|
return
|
|
342
372
|
|
|
343
373
|
teacher_blob = "\n".join(teacher_texts)
|
|
344
|
-
|
|
345
|
-
entries = history.read(limit=0)
|
|
346
|
-
last_seen: dict[str, float] = {}
|
|
347
|
-
for e in entries:
|
|
348
|
-
text = e.get("text", "")
|
|
349
|
-
ts = e.get("ts", 0.0)
|
|
350
|
-
low = text.lower()
|
|
351
|
-
for term in terms:
|
|
352
|
-
if term.lower() in low and ts > last_seen.get(term, 0.0):
|
|
353
|
-
last_seen[term] = ts
|
|
374
|
+
last_seen = _last_seen_map(terms)
|
|
354
375
|
|
|
355
376
|
fresh: list[str] = []
|
|
356
377
|
aged: list[str] = []
|