python-voiceio 0.8.3__tar.gz → 0.9.0__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.
Files changed (130) hide show
  1. {python_voiceio-0.8.3/python_voiceio.egg-info → python_voiceio-0.9.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.8.3 → python_voiceio-0.9.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/python_voiceio.egg-info/SOURCES.txt +2 -0
  5. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_adjudicate.py +14 -1
  6. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_correct_batch.py +85 -3
  7. python_voiceio-0.9.0/tests/test_evaluate.py +198 -0
  8. python_voiceio-0.9.0/voiceio/__init__.py +1 -0
  9. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/cli.py +116 -5
  10. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/config.py +11 -0
  11. python_voiceio-0.9.0/voiceio/evaluate.py +322 -0
  12. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/clipboard.py +26 -1
  13. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/ibus.py +34 -4
  14. python_voiceio-0.8.3/voiceio/__init__.py +0 -1
  15. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/LICENSE +0 -0
  16. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/README.md +0 -0
  17. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  18. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  19. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/python_voiceio.egg-info/requires.txt +0 -0
  20. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/python_voiceio.egg-info/top_level.txt +0 -0
  21. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/setup.cfg +0 -0
  22. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_app_wiring.py +0 -0
  23. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_audio_quality.py +0 -0
  24. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_audit.py +0 -0
  25. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_autocorrect.py +0 -0
  26. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_autocorrect_state.py +0 -0
  27. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_cli.py +0 -0
  29. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_clipboard_read.py +0 -0
  30. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_commands.py +0 -0
  31. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_concurrency_lockdown.py +0 -0
  32. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_config.py +0 -0
  33. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_corrections.py +0 -0
  34. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_fallback.py +0 -0
  35. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_health.py +0 -0
  36. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_hints.py +0 -0
  37. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_history.py +0 -0
  38. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_ibus_pending.py +0 -0
  39. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_ibus_ping.py +0 -0
  40. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_ibus_typer.py +0 -0
  41. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_llm.py +0 -0
  42. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_llm_api.py +0 -0
  43. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_numbers.py +0 -0
  44. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_platform.py +0 -0
  45. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_postcorrect.py +0 -0
  46. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_postprocess.py +0 -0
  47. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_prebuffer.py +0 -0
  48. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_prompt.py +0 -0
  49. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_recorder_integration.py +0 -0
  50. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_retention.py +0 -0
  51. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_robustness.py +0 -0
  52. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_security_hardening.py +0 -0
  53. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_snapshots.py +0 -0
  54. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_streaming.py +0 -0
  55. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_tokens.py +0 -0
  56. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_transcriber.py +0 -0
  57. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_tts.py +0 -0
  58. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_vad.py +0 -0
  59. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_vocabulary.py +0 -0
  60. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_wizard.py +0 -0
  61. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/tests/test_wordfreq.py +0 -0
  62. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/__main__.py +0 -0
  63. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/app.py +0 -0
  64. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/audit.py +0 -0
  65. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/autocorrect.py +0 -0
  66. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/autocorrect_state.py +0 -0
  67. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/backends.py +0 -0
  68. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/clipboard_read.py +0 -0
  69. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/commands.py +0 -0
  70. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/consent.py +0 -0
  71. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/corrections.py +0 -0
  72. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/demo.py +0 -0
  73. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/feedback.py +0 -0
  74. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/health.py +0 -0
  75. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hints.py +0 -0
  76. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/history.py +0 -0
  77. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hotkeys/__init__.py +0 -0
  78. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hotkeys/base.py +0 -0
  79. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hotkeys/chain.py +0 -0
  80. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hotkeys/evdev.py +0 -0
  81. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  82. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/hotkeys/socket_backend.py +0 -0
  83. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/ibus/__init__.py +0 -0
  84. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/ibus/engine.py +0 -0
  85. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/ibus/pending.py +0 -0
  86. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/llm.py +0 -0
  87. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/llm_api.py +0 -0
  88. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/models/__init__.py +0 -0
  89. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/models/silero_vad.onnx +0 -0
  90. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/numbers.py +0 -0
  91. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/pidlock.py +0 -0
  92. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/platform.py +0 -0
  93. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/postcorrect.py +0 -0
  94. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/postprocess.py +0 -0
  95. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/prompt.py +0 -0
  96. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/recorder.py +0 -0
  97. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/retention.py +0 -0
  98. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/service.py +0 -0
  99. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/snapshots.py +0 -0
  100. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/sounds/__init__.py +0 -0
  101. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/sounds/commit.wav +0 -0
  102. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/sounds/start.wav +0 -0
  103. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/sounds/stop.wav +0 -0
  104. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/streaming.py +0 -0
  105. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tokens.py +0 -0
  106. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/transcriber.py +0 -0
  107. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tray/__init__.py +0 -0
  108. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tray/_icons.py +0 -0
  109. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tray/_indicator.py +0 -0
  110. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tray/_pystray.py +0 -0
  111. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/__init__.py +0 -0
  112. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/base.py +0 -0
  113. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/chain.py +0 -0
  114. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/edge_engine.py +0 -0
  115. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/espeak.py +0 -0
  116. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/piper_engine.py +0 -0
  117. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/tts/player.py +0 -0
  118. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/__init__.py +0 -0
  119. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/base.py +0 -0
  120. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/chain.py +0 -0
  121. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/pynput_type.py +0 -0
  122. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/wtype.py +0 -0
  123. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/xdotool.py +0 -0
  124. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/typers/ydotool.py +0 -0
  125. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/vad.py +0 -0
  126. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/vocab_stats.py +0 -0
  127. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/vocabulary.py +0 -0
  128. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/wizard.py +0 -0
  129. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/wordfreq.py +0 -0
  130. {python_voiceio-0.8.3 → python_voiceio-0.9.0}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.3
3
+ Version: 0.9.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.8.3"
7
+ version = "0.9.0"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.3
3
+ Version: 0.9.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ tests/test_concurrency_lockdown.py
21
21
  tests/test_config.py
22
22
  tests/test_correct_batch.py
23
23
  tests/test_corrections.py
24
+ tests/test_evaluate.py
24
25
  tests/test_fallback.py
25
26
  tests/test_health.py
26
27
  tests/test_hints.py
@@ -63,6 +64,7 @@ voiceio/config.py
63
64
  voiceio/consent.py
64
65
  voiceio/corrections.py
65
66
  voiceio/demo.py
67
+ voiceio/evaluate.py
66
68
  voiceio/feedback.py
67
69
  voiceio/health.py
68
70
  voiceio/hints.py
@@ -212,6 +212,17 @@ def _suspicious():
212
212
 
213
213
 
214
214
  class TestBatchFlow:
215
+ """The adjudication flow. Correction mining is retired by default
216
+ ([autocorrect] mine_corrections = false, see test_correct_batch.py), so
217
+ these opt it back in — the machinery still has to be correct for anyone who
218
+ turns it on, and for the vocabulary path that shares it."""
219
+
220
+ def _cfg(self):
221
+ from voiceio.config import load
222
+ cfg = load()
223
+ cfg.autocorrect.mine_corrections = True
224
+ return cfg
225
+
215
226
  def _run(self, tmp_path, adj, *, review=None, suspicious=None, notify=None):
216
227
  from voiceio.autocorrect import ReviewResult
217
228
  cd = CorrectionDict(path=tmp_path / "c.json")
@@ -219,7 +230,8 @@ class TestBatchFlow:
219
230
  ask_user=[{"wrong": "mantekka", "right": "", "reason": "?"}],
220
231
  )
221
232
  suspicious = suspicious if suspicious is not None else _suspicious()
222
- with patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
233
+ with patch("voiceio.config.load", return_value=self._cfg()), \
234
+ patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
223
235
  patch("voiceio.history.read", return_value=_entries()), \
224
236
  patch("voiceio.autocorrect.find_suspicious_words",
225
237
  return_value=suspicious), \
@@ -274,6 +286,7 @@ class TestBatchFlow:
274
286
 
275
287
  cd = CorrectionDict(path=tmp_path / "c.json")
276
288
  with caplog.at_level(logging.INFO, logger="voiceio.cli"), \
289
+ patch("voiceio.config.load", return_value=self._cfg()), \
277
290
  patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
278
291
  patch("voiceio.history.read", return_value=_entries()), \
279
292
  patch("voiceio.autocorrect.find_suspicious_words", return_value=many), \
@@ -20,6 +20,14 @@ def _suspicious():
20
20
  contexts=["check the mantekka transfers"])]
21
21
 
22
22
 
23
+ def _cfg_with_mining():
24
+ """Config with the retired correction mining explicitly opted back in."""
25
+ from voiceio.config import load
26
+ cfg = load()
27
+ cfg.autocorrect.mine_corrections = True
28
+ return cfg
29
+
30
+
23
31
  class TestBatchMode:
24
32
  def test_no_api_key_bails_quietly(self, tmp_path, capsys):
25
33
  cd = CorrectionDict(path=tmp_path / "c.json")
@@ -29,7 +37,11 @@ class TestBatchMode:
29
37
  assert "skipping batch" in capsys.readouterr().out.lower()
30
38
 
31
39
  def test_ambiguous_item_deferred_no_notify_cursor_advances(self, tmp_path):
32
- """No consensus → silently deferred; no queue, no notification, cursor advances."""
40
+ """No consensus → silently deferred; no queue, no notification, cursor advances.
41
+
42
+ Opts mining back in: it's retired by default (see
43
+ TestCorrectionMiningRetired) but the machinery must still be correct.
44
+ """
33
45
  cd = CorrectionDict(path=tmp_path / "c.json")
34
46
  review = ReviewResult()
35
47
  review.ask_user.append({"wrong": "mantekka", "right": "Manteca",
@@ -37,7 +49,8 @@ class TestBatchMode:
37
49
  adj = AdjudicationResult(
38
50
  deferred=[{"wrong": "mantekka", "right": "Manteca", "votes": []}],
39
51
  )
40
- with patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
52
+ with patch("voiceio.config.load", return_value=_cfg_with_mining()), \
53
+ patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
41
54
  patch("voiceio.history.read", return_value=_entries()), \
42
55
  patch("voiceio.autocorrect.find_suspicious_words",
43
56
  return_value=_suspicious()), \
@@ -54,11 +67,13 @@ class TestBatchMode:
54
67
  assert "mantekka" in state.deferred
55
68
 
56
69
  def test_unanimous_correction_applied_and_notified(self, tmp_path):
70
+ """Mining opted back in — it's retired by default, not deleted."""
57
71
  cd = CorrectionDict(path=tmp_path / "c.json")
58
72
  review = ReviewResult()
59
73
  review.ask_user.append({"wrong": "mantekka", "right": "", "reason": "?"})
60
74
  adj = AdjudicationResult(apply=[{"wrong": "mantekka", "right": "manteca"}])
61
- with patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
75
+ with patch("voiceio.config.load", return_value=_cfg_with_mining()), \
76
+ patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
62
77
  patch("voiceio.history.read", return_value=_entries()), \
63
78
  patch("voiceio.autocorrect.find_suspicious_words",
64
79
  return_value=_suspicious()), \
@@ -127,3 +142,70 @@ class TestProtectLanguages:
127
142
  assert gate_correction("ngroc", "ngrok",
128
143
  vocabulary={"ngrok"},
129
144
  protect_languages=["es", "ca"]) is None
145
+
146
+
147
+ class TestCorrectionMiningRetired:
148
+ """Correction mining is off by default.
149
+
150
+ Measured over months: 387 rules mined, 4 ever fired (1%), while the runtime
151
+ postcorrect pass applied 288 edits. Rules are exact-string matches, so they
152
+ only fire if Whisper repeats a misrecognition verbatim — and the errors
153
+ worth fixing ("nuance" for "neurons") are common words the safety gate must
154
+ refuse. One run learned rules that destroyed real Spanish words. Vocabulary
155
+ mining is a different story and stays on.
156
+ """
157
+
158
+ def _review(self):
159
+ # "compain" -> "company" passes gate_correction: the target is a common
160
+ # word. ("olamma" -> "Ollama" would be rejected because the target is
161
+ # neither common nor already in the vocabulary — pre-existing gate
162
+ # behaviour, unrelated to mining being retired.)
163
+ from voiceio.autocorrect import ReviewResult
164
+ return ReviewResult(
165
+ auto_fix=[{"wrong": "compain", "right": "company", "reason": "typo"}],
166
+ ask_user=[{"wrong": "wordal", "right": "Wordle", "reason": "maybe"}],
167
+ vocabulary=["Kubernetes"],
168
+ )
169
+
170
+ def test_default_is_off(self):
171
+ from voiceio.config import AutocorrectConfig
172
+ assert AutocorrectConfig().mine_corrections is False
173
+
174
+ def test_no_rules_learned_by_default(self):
175
+ """The whole point: a batch run must not grow corrections.json."""
176
+ from voiceio.corrections import CorrectionDict
177
+
178
+ cd = CorrectionDict()
179
+ with patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
180
+ patch("voiceio.history.read", return_value=_entries()), \
181
+ patch("voiceio.autocorrect.find_suspicious_words", return_value=_suspicious()), \
182
+ patch("voiceio.autocorrect.review_suspicious", return_value=self._review()), \
183
+ patch("voiceio.autocorrect.adjudicate") as adj, \
184
+ patch("voiceio.vocabulary.add_terms", return_value=1) as add_terms, \
185
+ patch("voiceio.feedback.notify"):
186
+ _cmd_correct_auto(cd, batch=True)
187
+
188
+ assert cd.list_all() == {} # no rules learned
189
+ assert not adj.called # and no LLM spend adjudicating them
190
+ assert add_terms.called # vocabulary mining still runs
191
+
192
+ def test_opt_in_still_mines(self):
193
+ """The machinery is retired, not deleted — it must still work if asked."""
194
+ from voiceio.autocorrect import AdjudicationResult
195
+ from voiceio.corrections import CorrectionDict
196
+
197
+ cfg = _cfg_with_mining()
198
+ cd = CorrectionDict()
199
+ adj = AdjudicationResult(apply=[], vocabulary=[], deferred=[])
200
+ with patch("voiceio.config.load", return_value=cfg), \
201
+ patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
202
+ patch("voiceio.history.read", return_value=_entries()), \
203
+ patch("voiceio.autocorrect.find_suspicious_words", return_value=_suspicious()), \
204
+ patch("voiceio.autocorrect.review_suspicious", return_value=self._review()), \
205
+ patch("voiceio.autocorrect.adjudicate", return_value=adj) as adjudicate, \
206
+ patch("voiceio.vocabulary.add_terms", return_value=1), \
207
+ patch("voiceio.feedback.notify"):
208
+ _cmd_correct_auto(cd, batch=True)
209
+
210
+ assert "compain" in cd.list_all() # gate-passing auto_fix applied
211
+ assert adjudicate.called # ambiguous ones still adjudicated
@@ -0,0 +1,198 @@
1
+ """Tests for the decoder-config eval harness.
2
+
3
+ The harness exists because every decoder knob was set from a plausible story and
4
+ one example. Its own detectors must not be in the same position — hence real
5
+ assertions on the hallucination/repetition counters, using the exact shapes seen
6
+ in production.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import wave
11
+ from unittest.mock import patch
12
+
13
+ import numpy as np
14
+
15
+ from voiceio.evaluate import (
16
+ count_hallucinations,
17
+ count_repetitions,
18
+ default_matrix,
19
+ evaluate,
20
+ sample_clips,
21
+ )
22
+
23
+
24
+ class TestHallucinationDetection:
25
+ def test_catches_whisper_filler(self):
26
+ assert count_hallucinations([" Thank you."]) == 1
27
+ assert count_hallucinations(["Thanks for watching!"]) == 1
28
+ assert count_hallucinations([" Thank you.", " Thank you."]) == 2
29
+
30
+ def test_ignores_real_speech(self):
31
+ assert count_hallucinations(["Thank you for the detailed review"]) == 0
32
+ assert count_hallucinations(["so I want you to own this end to end"]) == 0
33
+
34
+ def test_matches_whole_segment_not_substring(self):
35
+ """'thank you' inside a sentence is speech, not a hallucination."""
36
+ assert count_hallucinations(["I say thank you to the team"]) == 0
37
+
38
+ def test_empty(self):
39
+ assert count_hallucinations([]) == 0
40
+ assert count_hallucinations(["", " "]) == 0
41
+
42
+
43
+ class TestRepetitionDetection:
44
+ def test_catches_the_real_loop(self):
45
+ """Verbatim from a 422s dictation that looped four times."""
46
+ text = ("make a hypothesis why intelligence is not working out "
47
+ + "because it should not work out in the complex world " * 4
48
+ + "so we have seen simpler simulations")
49
+ assert count_repetitions(text) >= 1
50
+
51
+ def test_ignores_normal_speech(self):
52
+ text = ("So on each generation we have an algorithm to determine the "
53
+ "general shape of the brain and once that is determined we add "
54
+ "one or two nodes or more hidden nodes and more connections")
55
+ assert count_repetitions(text) == 0
56
+
57
+ def test_ignores_short_text(self):
58
+ assert count_repetitions("testing testing one two three") == 0
59
+
60
+ def test_needs_a_real_run_not_two(self):
61
+ """Saying something twice is emphasis; three-plus is a decode loop."""
62
+ twice = "the quick brown fox the quick brown fox and then it stopped"
63
+ assert count_repetitions(twice) == 0
64
+
65
+
66
+ class TestDefaultMatrix:
67
+ def test_includes_shipped_baseline(self):
68
+ names = [c.name for c in default_matrix()]
69
+ assert "shipped" in names
70
+
71
+ def test_shipped_matches_the_worker(self):
72
+ """If worker.py's decode params drift, this baseline is a lie."""
73
+ shipped = next(c for c in default_matrix() if c.name == "shipped")
74
+ src = (__import__("pathlib").Path(__file__).resolve().parent.parent
75
+ / "voiceio" / "worker.py").read_text()
76
+ assert "condition_on_previous_text=False" in src
77
+ assert "vad_filter=True" in src
78
+ assert shipped.condition_on_previous_text is False
79
+ assert shipped.vad_filter is True
80
+
81
+ def test_never_pairs_cond_with_hotwords(self):
82
+ """They share faster-whisper's sot_prev slot: 223 + 223 + sot > 448,
83
+ which raises 'maximum decoding length must be > 0'. Not a config."""
84
+ for c in default_matrix():
85
+ assert not (c.condition_on_previous_text and c.hotwords), c.name
86
+
87
+
88
+ def _write_wav(path, secs=1.0):
89
+ with wave.open(str(path), "wb") as w:
90
+ w.setnchannels(1)
91
+ w.setsampwidth(2)
92
+ w.setframerate(16000)
93
+ w.writeframes((np.zeros(int(16000 * secs), dtype=np.int16)).tobytes())
94
+
95
+
96
+ class TestSampleClips:
97
+ def test_respects_audio_budget(self, tmp_path, monkeypatch):
98
+ rec = tmp_path / "recordings"
99
+ rec.mkdir()
100
+ for i in range(10):
101
+ _write_wav(rec / f"c{i}.wav", secs=2.0)
102
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
103
+ got = sample_clips(5.0)
104
+ assert 0 < len(got) <= 3 # 2s each, budget 5s
105
+
106
+ def test_no_recordings_dir(self, tmp_path, monkeypatch):
107
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", tmp_path / "nope")
108
+ assert sample_clips(100) == []
109
+
110
+ def test_includes_long_clips(self, tmp_path, monkeypatch):
111
+ """The bias this replaced: newest-first sampling was ALL short clips
112
+ (8 clips, 22s avg), so condition_on_previous_text's repetition loops —
113
+ which only happen on long dictation — could not appear, and the harness
114
+ happily recommended cond=True."""
115
+ rec = tmp_path / "recordings"
116
+ rec.mkdir()
117
+ for i in range(30): # many short, newest
118
+ _write_wav(rec / f"short{i:02d}.wav", secs=10.0)
119
+ _write_wav(rec / "long.wav", secs=120.0)
120
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
121
+
122
+ got = sample_clips(300)
123
+ names = [p.name for p in got]
124
+ assert "long.wav" in names
125
+ assert any(n.startswith("short") for n in names)
126
+
127
+ def test_all_short_still_fills_budget(self, tmp_path, monkeypatch):
128
+ """No long recordings must not mean a half-empty sample."""
129
+ rec = tmp_path / "recordings"
130
+ rec.mkdir()
131
+ for i in range(20):
132
+ _write_wav(rec / f"s{i:02d}.wav", secs=10.0)
133
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
134
+ got = sample_clips(100)
135
+ assert len(got) >= 9 # ~10 x 10s, not capped at the 50% short share
136
+
137
+
138
+ class TestEvaluate:
139
+ def test_no_audio_returns_empty(self, tmp_path, monkeypatch):
140
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", tmp_path / "nope")
141
+ from voiceio.config import Config
142
+ assert evaluate(Config(), default_matrix(), teacher=lambda p: "x") == []
143
+
144
+ def test_teacher_silence_returns_empty(self, tmp_path, monkeypatch):
145
+ """A teacher that hears nothing gives nothing to score against."""
146
+ rec = tmp_path / "recordings"
147
+ rec.mkdir()
148
+ _write_wav(rec / "a.wav")
149
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
150
+ from voiceio.config import Config
151
+ assert evaluate(Config(), default_matrix(), teacher=lambda p: "") == []
152
+
153
+ def test_scores_and_ranks_by_wer(self, tmp_path, monkeypatch):
154
+ rec = tmp_path / "recordings"
155
+ rec.mkdir()
156
+ _write_wav(rec / "a.wav", secs=1.0)
157
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
158
+
159
+ class _Seg:
160
+ def __init__(self, t):
161
+ self.text = t
162
+
163
+ class _Model:
164
+ def transcribe(self, audio, **kw):
165
+ # The no-vad config "hears" the reference exactly; shipped drops
166
+ # a word. Ranking must follow WER, not declaration order.
167
+ if kw.get("vad_filter"):
168
+ return iter([_Seg("hello there")]), None
169
+ return iter([_Seg("hello there world")]), None
170
+
171
+ from voiceio.config import Config
172
+ with patch("voiceio.worker.load_model", return_value=_Model()), \
173
+ patch("voiceio.vocabulary.load_terms", return_value=[]):
174
+ scores = evaluate(Config(), default_matrix(),
175
+ teacher=lambda p: "hello there world")
176
+
177
+ assert scores
178
+ assert scores[0].wer <= scores[-1].wer # sorted best-first
179
+ assert scores[0].config.vad_filter is False # the exact-match config
180
+ assert all(s.clips == 1 for s in scores)
181
+
182
+ def test_a_crashing_config_is_a_result_not_an_error(self, tmp_path, monkeypatch):
183
+ rec = tmp_path / "recordings"
184
+ rec.mkdir()
185
+ _write_wav(rec / "a.wav")
186
+ monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
187
+
188
+ class _Model:
189
+ def transcribe(self, audio, **kw):
190
+ raise ValueError("The maximum decoding length must be > 0")
191
+
192
+ from voiceio.config import Config
193
+ with patch("voiceio.worker.load_model", return_value=_Model()), \
194
+ patch("voiceio.vocabulary.load_terms", return_value=[]):
195
+ scores = evaluate(Config(), default_matrix(), teacher=lambda p: "hi")
196
+
197
+ assert all(s.crashes == 1 for s in scores)
198
+ assert all(s.clips == 0 for s in scores)
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -110,6 +110,14 @@ def main() -> None:
110
110
  p_history.add_argument("--path", action="store_true",
111
111
  help="Print history file path")
112
112
 
113
+ # ── voiceio eval ───────────────────────────────────────────────────
114
+ p_eval = sub.add_parser(
115
+ "eval", help="Replay your retained audio across decoder configs and score them")
116
+ p_eval.add_argument("--max-audio-secs", type=float, default=900,
117
+ help="Audio budget to replay (default: 900 = 15min)")
118
+ p_eval.add_argument("--json", action="store_true",
119
+ help="Emit raw results as JSON")
120
+
113
121
  # ── voiceio demo ──────────────────────────────────────────────────
114
122
  sub.add_parser("demo", help="Interactive guided tour of voiceio features")
115
123
 
@@ -136,6 +144,8 @@ def main() -> None:
136
144
  _cmd_correct(args)
137
145
  elif args.command == "history":
138
146
  _cmd_history(args)
147
+ elif args.command == "eval":
148
+ _cmd_eval(args)
139
149
  elif args.command == "demo":
140
150
  _cmd_demo()
141
151
  elif args.command == "logs":
@@ -720,6 +730,91 @@ def _cmd_uninstall() -> None:
720
730
  print("\nvoiceio fully removed.")
721
731
 
722
732
 
733
+ def _cmd_eval(args: argparse.Namespace) -> None:
734
+ """Score decoder configs against a teacher model on your own audio."""
735
+ import json as _json
736
+ import sys
737
+ import time
738
+
739
+ from voiceio.config import load
740
+ from voiceio.evaluate import default_matrix, evaluate
741
+ from voiceio.wizard import BOLD, DIM, GREEN, RESET, YELLOW
742
+
743
+ cfg = load()
744
+ matrix = default_matrix()
745
+
746
+ print(f"{BOLD}Evaluating {len(matrix)} decoder configs{RESET} "
747
+ f"{DIM}on up to {args.max_audio_secs:.0f}s of your retained audio{RESET}")
748
+ print(f"{DIM}Scored against a teacher model (distil-large-v3) — a proxy for")
749
+ print(f"ground truth, good for ranking configs, not for absolute accuracy.{RESET}\n")
750
+
751
+ last = [""]
752
+
753
+ def progress(name, i, n):
754
+ if name != last[0]:
755
+ last[0] = name
756
+ sys.stderr.write(f"\n {name}: ")
757
+ sys.stderr.write(".")
758
+ sys.stderr.flush()
759
+
760
+ t0 = time.time()
761
+ scores = evaluate(cfg, matrix, max_audio_secs=args.max_audio_secs,
762
+ on_progress=progress)
763
+ sys.stderr.write("\n\n")
764
+
765
+ if not scores:
766
+ print("No retained audio to evaluate. Dictate a few notes first "
767
+ "(audio is kept under [data] retain_audio).")
768
+ return
769
+
770
+ if args.json:
771
+ print(_json.dumps([{
772
+ "config": s.config.name, **s.config.as_dict(),
773
+ "clips": s.clips, "audio_secs": round(s.audio_secs, 1),
774
+ "wer": s.wer, "hallucinations": s.hallucinations,
775
+ "repetitions": s.repetitions, "empties": s.empties,
776
+ "crashes": s.crashes, "decode_secs": round(s.decode_secs, 1),
777
+ } for s in scores], indent=2))
778
+ return
779
+
780
+ best = scores[0]
781
+ shipped = next((s for s in scores if s.config.name == "shipped"), None)
782
+
783
+ print(f" {'config':<24} {'WER':>7} {'halluc':>7} {'repeat':>7} "
784
+ f"{'empty':>6} {'crash':>6}")
785
+ print(f" {'-'*24} {'-'*7} {'-'*7} {'-'*7} {'-'*6} {'-'*6}")
786
+ for s in scores:
787
+ mark = f" {GREEN}<- best{RESET}" if s is best else ""
788
+ if s is shipped and s is not best:
789
+ mark = f" {DIM}<- shipped{RESET}"
790
+ elif s is shipped:
791
+ mark = f" {GREEN}<- best (shipped){RESET}"
792
+ print(f" {s.config.name:<24} {s.wer:>7.3f} {s.hallucinations:>7} "
793
+ f"{s.repetitions:>7} {s.empties:>6} {s.crashes:>6}{mark}")
794
+
795
+ print(f"\n {DIM}{best.clips} clips, {best.audio_secs:.0f}s audio, "
796
+ f"{time.time()-t0:.0f}s total{RESET}")
797
+
798
+ if shipped and best is not shipped:
799
+ delta = (shipped.wer - best.wer) / shipped.wer * 100 if shipped.wer else 0
800
+ print(f"\n {YELLOW}'{best.config.name}' beats the shipped config by "
801
+ f"{delta:.0f}% WER.{RESET}")
802
+ # A WER win means nothing if it comes from the failure mode the knob
803
+ # exists to prevent, so make that trade explicit rather than implied.
804
+ if best.hallucinations > shipped.hallucinations:
805
+ print(f" {YELLOW}But it hallucinates more "
806
+ f"({best.hallucinations} vs {shipped.hallucinations}) — "
807
+ f"that's what vad_filter is for.{RESET}")
808
+ if best.repetitions > shipped.repetitions:
809
+ print(f" {YELLOW}But it loops more "
810
+ f"({best.repetitions} vs {shipped.repetitions}) — "
811
+ f"that's what condition_on_previous_text=False is for.{RESET}")
812
+ print(f" {DIM}The teacher is a proxy, not truth. Spot-check before "
813
+ f"changing a default.{RESET}")
814
+ elif shipped:
815
+ print(f"\n {GREEN}The shipped config wins. No change indicated.{RESET}")
816
+
817
+
723
818
  def _show_vocab() -> None:
724
819
  """Show the vocabulary split by what actually reaches the decoder.
725
820
 
@@ -989,12 +1084,20 @@ def _cmd_correct_auto(cd, *, full: bool = False, batch: bool = False) -> None:
989
1084
  # Build lookup for O(1) access to suspicious word metadata
990
1085
  sw_by_word = {sw.word: sw for sw in suspicious}
991
1086
 
1087
+ # Correction mining is retired by default ([autocorrect] mine_corrections).
1088
+ # The vocabulary bucket below is unaffected: those terms bias the decoder
1089
+ # and measurably help, whereas mined find/replace rules fired 4 times in 387
1090
+ # while postcorrect applied 288 edits over the same span. Skipping the
1091
+ # correction buckets also skips adjudication — the expensive part, at
1092
+ # votes x ceil(N/25) LLM calls per run.
1093
+ mine_corrections = cfg.autocorrect.mine_corrections
1094
+
992
1095
  # ── Bucket 1: Auto-fix (high confidence, safety-gated) ──────────────
993
1096
  # Pairs failing the gate (real word being "corrected", or target that is
994
1097
  # itself junk) are downgraded to manual review rather than persisted.
995
1098
  auto_fixed = 0
996
1099
  downgraded: list[dict] = []
997
- if result.auto_fix:
1100
+ if result.auto_fix and mine_corrections:
998
1101
  gated = []
999
1102
  for fix in result.auto_fix:
1000
1103
  reason = gate_correction(
@@ -1028,10 +1131,13 @@ def _cmd_correct_auto(cd, *, full: bool = False, batch: bool = False) -> None:
1028
1131
  )
1029
1132
 
1030
1133
  # ── Bucket 2: Ask user (ambiguous) ──────────────────────────────────
1031
- to_review = downgraded + list(result.ask_user)
1134
+ # Empty when correction mining is retired — nothing to adjudicate or review.
1135
+ to_review = (downgraded + list(result.ask_user)) if mine_corrections else []
1032
1136
 
1033
1137
  # If no LLM was used, put all suspicious words into manual review
1034
- if not has_api and not has_ollama:
1138
+ if not mine_corrections:
1139
+ pass # correction mining retired — nothing to review or adjudicate
1140
+ elif not has_api and not has_ollama:
1035
1141
  for sw in suspicious:
1036
1142
  to_review.append({
1037
1143
  "wrong": sw.word,
@@ -1087,8 +1193,13 @@ def _cmd_correct_auto(cd, *, full: bool = False, batch: bool = False) -> None:
1087
1193
  print(f" {DIM}{len(capped)} lower-frequency item(s) deferred "
1088
1194
  f"to next run{RESET}")
1089
1195
 
1090
- adj = adjudicate(cfg, to_review, sw_by_word,
1091
- vocabulary=vocab_words, language=language)
1196
+ if to_review:
1197
+ adj = adjudicate(cfg, to_review, sw_by_word,
1198
+ vocabulary=vocab_words, language=language)
1199
+ else:
1200
+ # Nothing to weigh — don't spend votes x ceil(N/25) LLM calls proving it.
1201
+ from voiceio.autocorrect import AdjudicationResult
1202
+ adj = AdjudicationResult()
1092
1203
 
1093
1204
  # Unanimous corrections (already gate-passed inside adjudicate).
1094
1205
  adj_applied = 0
@@ -138,6 +138,17 @@ class AutocorrectConfig:
138
138
  # Languages you also dictate in: mined corrections never rewrite words
139
139
  # that are real in these (e.g. ["es"] protects Spanish "harina").
140
140
  protect_languages: list[str] = field(default_factory=list)
141
+ # Mine find/replace CORRECTION rules from history. Off by default: measured
142
+ # over months of real use, mining produced 387 rules of which 4 ever fired
143
+ # (1%), while the runtime postcorrect pass applied 288 edits over the same
144
+ # period. Rules are exact-string matches, so they only fire when Whisper
145
+ # repeats a misrecognition verbatim — and the errors worth fixing are
146
+ # common words ("nuance" for "neurons") that the safety gate must refuse
147
+ # anyway. Meanwhile a bad rule is a silent regression: one run learned rules
148
+ # destroying real Spanish words. Poor odds, real downside, and postcorrect
149
+ # already does this contextually. Vocabulary mining is unaffected and stays
150
+ # on — those terms bias the decoder and demonstrably help.
151
+ mine_corrections: bool = False
141
152
 
142
153
 
143
154
  @dataclass