python-voiceio 0.5.0__tar.gz → 0.6.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.
- {python_voiceio-0.5.0/python_voiceio.egg-info → python_voiceio-0.6.0}/PKG-INFO +1 -1
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/pyproject.toml +1 -1
- {python_voiceio-0.5.0 → python_voiceio-0.6.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_postcorrect.py +116 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_retention.py +69 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_streaming.py +220 -2
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_transcriber.py +36 -0
- python_voiceio-0.6.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/app.py +11 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/config.py +19 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/postcorrect.py +75 -6
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/retention.py +65 -1
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/streaming.py +140 -12
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/transcriber.py +22 -6
- python_voiceio-0.5.0/voiceio/__init__.py +0 -1
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/LICENSE +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/README.md +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/setup.cfg +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_config.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_health.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_history.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_robustness.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_wizard.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/audit.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/cli.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/health.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/history.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/recorder.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/service.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/wizard.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/worker.py +0 -0
|
@@ -161,3 +161,119 @@ def test_pipeline_runs_postcorrect_only_on_final():
|
|
|
161
161
|
)
|
|
162
162
|
assert text == "Crisp chat is the best tool"
|
|
163
163
|
assert not abort
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── wall-clock deadline ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_deadline_exceeded_keeps_original():
|
|
170
|
+
"""A chat call slower than timeout_secs is abandoned at the deadline."""
|
|
171
|
+
import time as _time
|
|
172
|
+
cfg = _cfg()
|
|
173
|
+
cfg.postcorrect.timeout_secs = 0.2
|
|
174
|
+
|
|
175
|
+
def slow_chat(*a, **kw):
|
|
176
|
+
_time.sleep(2.0)
|
|
177
|
+
return "Crisp chat is the tool"
|
|
178
|
+
|
|
179
|
+
pc = PostCorrector(cfg)
|
|
180
|
+
with patch("voiceio.llm_api.chat", side_effect=slow_chat):
|
|
181
|
+
t0 = _time.monotonic()
|
|
182
|
+
assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
|
|
183
|
+
elapsed = _time.monotonic() - t0
|
|
184
|
+
assert elapsed < 1.0 # returned at the deadline, not after the full call
|
|
185
|
+
assert pc.last_secs is not None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_within_deadline_applies_correction():
|
|
189
|
+
cfg = _cfg()
|
|
190
|
+
cfg.postcorrect.timeout_secs = 5.0
|
|
191
|
+
pc = PostCorrector(cfg)
|
|
192
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
193
|
+
assert pc.correct("Chris chat is the tool") == "Crisp chat is the tool"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── before/after pair persistence ────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_pairs(path):
|
|
200
|
+
import json
|
|
201
|
+
return [json.loads(line) for line in path.read_text().splitlines()]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_applied_correction_recorded(tmp_path, monkeypatch):
|
|
205
|
+
import voiceio.postcorrect as pcmod
|
|
206
|
+
from voiceio import config as cfgmod
|
|
207
|
+
pairs = tmp_path / "pairs.jsonl"
|
|
208
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
|
|
209
|
+
pc = PostCorrector(_cfg())
|
|
210
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
211
|
+
pc.correct("Chris chat is the tool")
|
|
212
|
+
entries = _read_pairs(pairs)
|
|
213
|
+
assert len(entries) == 1
|
|
214
|
+
assert entries[0]["outcome"] == "applied"
|
|
215
|
+
assert entries[0]["before"] == "Chris chat is the tool"
|
|
216
|
+
assert entries[0]["after"] == "Crisp chat is the tool"
|
|
217
|
+
assert entries[0]["secs"] is not None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_rejected_rewrite_recorded(tmp_path, monkeypatch):
|
|
221
|
+
from voiceio import config as cfgmod
|
|
222
|
+
pairs = tmp_path / "pairs.jsonl"
|
|
223
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
|
|
224
|
+
pc = PostCorrector(_cfg())
|
|
225
|
+
with patch("voiceio.llm_api.chat", return_value="Completely different rewritten sentence with many other words entirely"):
|
|
226
|
+
out = pc.correct("Chris chat is the tool")
|
|
227
|
+
assert out == "Chris chat is the tool"
|
|
228
|
+
entries = _read_pairs(pairs)
|
|
229
|
+
assert len(entries) == 1
|
|
230
|
+
assert entries[0]["outcome"].startswith("rejected")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_no_recording_when_disabled(tmp_path, monkeypatch):
|
|
234
|
+
from voiceio import config as cfgmod
|
|
235
|
+
pairs = tmp_path / "pairs.jsonl"
|
|
236
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
|
|
237
|
+
cfg = _cfg()
|
|
238
|
+
cfg.data.capture_intermediates = False
|
|
239
|
+
pc = PostCorrector(cfg)
|
|
240
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
241
|
+
pc.correct("Chris chat is the tool")
|
|
242
|
+
assert not pairs.exists()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_hung_request_blocks_next_call_not_thread_pileup(tmp_path, monkeypatch):
|
|
246
|
+
"""A worker abandoned at the deadline makes the NEXT call skip the LLM."""
|
|
247
|
+
import threading as _threading
|
|
248
|
+
import time as _time
|
|
249
|
+
from voiceio import config as cfgmod
|
|
250
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", tmp_path / "pairs.jsonl")
|
|
251
|
+
cfg = _cfg()
|
|
252
|
+
cfg.postcorrect.timeout_secs = 0.1
|
|
253
|
+
pc = PostCorrector(cfg)
|
|
254
|
+
release = _threading.Event()
|
|
255
|
+
|
|
256
|
+
def hung_chat(*a, **kw):
|
|
257
|
+
release.wait(5)
|
|
258
|
+
return "x"
|
|
259
|
+
|
|
260
|
+
with patch("voiceio.llm_api.chat", side_effect=hung_chat) as mock_chat:
|
|
261
|
+
assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
|
|
262
|
+
assert mock_chat.call_count == 1
|
|
263
|
+
# Second call while the first worker is still hung: no new thread
|
|
264
|
+
assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
|
|
265
|
+
assert mock_chat.call_count == 1
|
|
266
|
+
release.set()
|
|
267
|
+
entries = _read_pairs(tmp_path / "pairs.jsonl")
|
|
268
|
+
assert [e["outcome"] for e in entries] == ["timeout", "skipped_busy"]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_recorded_context_is_effective_context(tmp_path, monkeypatch):
|
|
272
|
+
from voiceio import config as cfgmod
|
|
273
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", tmp_path / "pairs.jsonl")
|
|
274
|
+
pc = PostCorrector(_cfg())
|
|
275
|
+
pc.set_context(title="Stale Window")
|
|
276
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
277
|
+
pc.correct("Chris chat is the tool", context="Fresh Window")
|
|
278
|
+
entries = _read_pairs(tmp_path / "pairs.jsonl")
|
|
279
|
+
assert entries[0]["context"] == "Fresh Window"
|
|
@@ -97,3 +97,72 @@ class TestHistoryExtra:
|
|
|
97
97
|
entry = json.loads(p.read_text())
|
|
98
98
|
assert entry["text"] == "hello"
|
|
99
99
|
assert entry["ts"] != 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── intermediate data capture + disk floor ──────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestAppendJsonl:
|
|
106
|
+
def test_appends_lines(self, tmp_path):
|
|
107
|
+
from voiceio import retention
|
|
108
|
+
p = tmp_path / "pairs.jsonl"
|
|
109
|
+
retention.append_jsonl(p, {"a": 1})
|
|
110
|
+
retention.append_jsonl(p, {"b": 2})
|
|
111
|
+
import json
|
|
112
|
+
lines = [json.loads(line) for line in p.read_text().splitlines()]
|
|
113
|
+
assert lines == [{"a": 1}, {"b": 2}]
|
|
114
|
+
|
|
115
|
+
def test_never_raises_on_bad_entry(self, tmp_path):
|
|
116
|
+
from voiceio import retention
|
|
117
|
+
retention.append_jsonl(tmp_path / "x.jsonl", {"bad": object()}) # not JSON-able
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestSaveTrace:
|
|
121
|
+
def test_writes_when_enabled(self, tmp_path, monkeypatch):
|
|
122
|
+
from voiceio import retention
|
|
123
|
+
from voiceio.config import DataConfig
|
|
124
|
+
monkeypatch.setattr(retention, "TRACES_PATH", tmp_path / "trace.jsonl")
|
|
125
|
+
retention.save_trace(DataConfig(), {"ts": 1, "passes": []})
|
|
126
|
+
assert (tmp_path / "trace.jsonl").exists()
|
|
127
|
+
|
|
128
|
+
def test_skips_when_disabled(self, tmp_path, monkeypatch):
|
|
129
|
+
from voiceio import retention
|
|
130
|
+
from voiceio.config import DataConfig
|
|
131
|
+
monkeypatch.setattr(retention, "TRACES_PATH", tmp_path / "trace.jsonl")
|
|
132
|
+
retention.save_trace(DataConfig(capture_intermediates=False), {"ts": 1})
|
|
133
|
+
assert not (tmp_path / "trace.jsonl").exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestDiskFloor:
|
|
137
|
+
def test_save_audio_skips_below_floor(self, monkeypatch):
|
|
138
|
+
import numpy as np
|
|
139
|
+
from voiceio import retention
|
|
140
|
+
from voiceio.config import DataConfig
|
|
141
|
+
monkeypatch.setattr(retention, "_free_gb", lambda p: 1.0)
|
|
142
|
+
name = retention.save_audio(
|
|
143
|
+
np.zeros(1600, dtype=np.float32), 0.0, DataConfig(min_free_gb=5.0),
|
|
144
|
+
)
|
|
145
|
+
assert name is None
|
|
146
|
+
|
|
147
|
+
def test_save_audio_ok_above_floor(self, tmp_path, monkeypatch):
|
|
148
|
+
import numpy as np
|
|
149
|
+
from voiceio import retention
|
|
150
|
+
from voiceio.config import DataConfig
|
|
151
|
+
monkeypatch.setattr(retention, "RECORDINGS_DIR", tmp_path)
|
|
152
|
+
monkeypatch.setattr(retention, "_free_gb", lambda p: 100.0)
|
|
153
|
+
name = retention.save_audio(
|
|
154
|
+
np.zeros(1600, dtype=np.float32), 0.0, DataConfig(min_free_gb=5.0),
|
|
155
|
+
)
|
|
156
|
+
assert name is not None and (tmp_path / name).exists()
|
|
157
|
+
|
|
158
|
+
def test_prune_shrinks_budget_below_floor(self, tmp_path, monkeypatch):
|
|
159
|
+
from voiceio import retention
|
|
160
|
+
from voiceio.config import DataConfig
|
|
161
|
+
monkeypatch.setattr(retention, "RECORDINGS_DIR", tmp_path)
|
|
162
|
+
monkeypatch.setattr(retention, "_free_gb", lambda p: 1.0)
|
|
163
|
+
for i in range(4):
|
|
164
|
+
(tmp_path / f"r{i}.wav").write_bytes(b"x" * 1000)
|
|
165
|
+
retention.prune(DataConfig(max_audio_mb=1024, min_free_gb=5.0))
|
|
166
|
+
# Budget halved from 4000 bytes total → keep <= 2000 bytes
|
|
167
|
+
remaining = sum(p.stat().st_size for p in tmp_path.glob("*.wav"))
|
|
168
|
+
assert remaining <= 2000
|
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
import threading
|
|
6
6
|
import time
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from unittest.mock import MagicMock
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
9
|
|
|
10
10
|
from voiceio.typers.base import TyperBackend
|
|
11
11
|
|
|
@@ -566,7 +566,7 @@ class TestWorkerLoop:
|
|
|
566
566
|
call_count = 0
|
|
567
567
|
transcriber = MagicMock()
|
|
568
568
|
|
|
569
|
-
def slow_transcribe(audio, final=False):
|
|
569
|
+
def slow_transcribe(audio, final=False, context=None):
|
|
570
570
|
nonlocal call_count
|
|
571
571
|
call_count += 1
|
|
572
572
|
time.sleep(0.2)
|
|
@@ -649,3 +649,221 @@ class TestOnInterim:
|
|
|
649
649
|
s, _ = self._make_session(None)
|
|
650
650
|
s._apply_correction("Hello world")
|
|
651
651
|
assert s.interim_text == "Hello world"
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# --- incremental finalization (segment freezing) ---
|
|
655
|
+
|
|
656
|
+
SR = 16000
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _silent(secs):
|
|
660
|
+
return np.zeros(int(SR * secs), dtype=np.float32)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _loud(secs):
|
|
664
|
+
return np.full(int(SR * secs), 0.5, dtype=np.float32)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class TestSegmentFreeze:
|
|
668
|
+
def _make_session(self, freeze_secs=2.0):
|
|
669
|
+
recorder = MagicMock()
|
|
670
|
+
recorder.sample_rate = SR
|
|
671
|
+
transcriber = MagicMock()
|
|
672
|
+
transcriber.last_segments = []
|
|
673
|
+
session = StreamingSession(
|
|
674
|
+
transcriber=transcriber,
|
|
675
|
+
typer=MagicMock(spec=TyperBackend),
|
|
676
|
+
recorder=recorder,
|
|
677
|
+
freeze_secs=freeze_secs,
|
|
678
|
+
)
|
|
679
|
+
return session, transcriber, recorder
|
|
680
|
+
|
|
681
|
+
def test_freeze_triggers_on_long_silent_tail(self):
|
|
682
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
683
|
+
rec.get_audio_so_far.return_value = _silent(3)
|
|
684
|
+
tr.transcribe.return_value = "hello there friend"
|
|
685
|
+
s._transcribe_and_apply()
|
|
686
|
+
assert s._frozen_raw == "hello there friend"
|
|
687
|
+
assert s._frozen_samples == SR * 3
|
|
688
|
+
# Freeze pass gets beam search (final=True)
|
|
689
|
+
assert tr.transcribe.call_args.kwargs["final"] is True
|
|
690
|
+
|
|
691
|
+
def test_no_freeze_when_tail_is_speech(self):
|
|
692
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
693
|
+
rec.get_audio_so_far.return_value = _loud(3)
|
|
694
|
+
tr.transcribe.return_value = "hello there friend"
|
|
695
|
+
s._transcribe_and_apply()
|
|
696
|
+
assert s._frozen_raw == ""
|
|
697
|
+
assert s._frozen_samples == 0
|
|
698
|
+
assert tr.transcribe.call_args.kwargs["final"] is False
|
|
699
|
+
|
|
700
|
+
def test_no_freeze_when_disabled(self):
|
|
701
|
+
s, tr, rec = self._make_session(freeze_secs=0)
|
|
702
|
+
rec.get_audio_so_far.return_value = _silent(30)
|
|
703
|
+
tr.transcribe.return_value = "hello there friend"
|
|
704
|
+
s._transcribe_and_apply()
|
|
705
|
+
assert s._frozen_raw == ""
|
|
706
|
+
|
|
707
|
+
def test_interim_after_freeze_decodes_only_tail(self):
|
|
708
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
709
|
+
s._frozen_raw = "hello there friend"
|
|
710
|
+
s._frozen_samples = SR * 3
|
|
711
|
+
rec.get_audio_so_far.return_value = np.concatenate([_silent(3), _loud(1.5)])
|
|
712
|
+
tr.transcribe.return_value = "how are"
|
|
713
|
+
s._transcribe_and_apply()
|
|
714
|
+
sent_audio = tr.transcribe.call_args.args[0]
|
|
715
|
+
assert len(sent_audio) == SR * 1.5 # only the un-frozen tail
|
|
716
|
+
assert "hello there friend" in tr.transcribe.call_args.kwargs["context"]
|
|
717
|
+
assert s._typed_text == "hello there friend how are"
|
|
718
|
+
|
|
719
|
+
def test_final_decodes_only_tail_and_merges(self):
|
|
720
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
721
|
+
s._frozen_raw = "hello there friend"
|
|
722
|
+
s._frozen_samples = SR * 3
|
|
723
|
+
s._final_audio = np.concatenate([_silent(3), _loud(2)])
|
|
724
|
+
tr.transcribe.return_value = "how are you"
|
|
725
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
726
|
+
sent_audio = tr.transcribe.call_args.args[0]
|
|
727
|
+
assert len(sent_audio) == SR * 2
|
|
728
|
+
assert s.raw_final_text == "hello there friend how are you"
|
|
729
|
+
assert s.final_latency["frozen_secs"] == 3.0
|
|
730
|
+
assert s.final_latency["tail_secs"] == 2.0
|
|
731
|
+
assert s._typed_text == "hello there friend how are you"
|
|
732
|
+
|
|
733
|
+
def test_final_with_silent_tail_commits_frozen_text(self):
|
|
734
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
735
|
+
s._frozen_raw = "hello there friend"
|
|
736
|
+
s._frozen_samples = SR * 3
|
|
737
|
+
# Tiny trailing tail is still decoded (a clipped last word must not
|
|
738
|
+
# be dropped); here it is genuinely silent, so frozen text commits.
|
|
739
|
+
s._final_audio = np.concatenate([_silent(3), _silent(0.1)])
|
|
740
|
+
tr.transcribe.return_value = ""
|
|
741
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
742
|
+
tr.transcribe.assert_called_once()
|
|
743
|
+
assert s.raw_final_text == "hello there friend"
|
|
744
|
+
assert s._typed_text == "hello there friend"
|
|
745
|
+
|
|
746
|
+
def test_frozen_segments_accumulate_into_final(self):
|
|
747
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
748
|
+
rec.get_audio_so_far.return_value = _silent(3)
|
|
749
|
+
tr.transcribe.return_value = "hello there friend"
|
|
750
|
+
tr.last_segments = [{"text": "hello there friend", "avg_logprob": -0.2}]
|
|
751
|
+
s._transcribe_and_apply() # freeze pass
|
|
752
|
+
s._final_audio = np.concatenate([_silent(3), _loud(2)])
|
|
753
|
+
tr.transcribe.return_value = "how are you"
|
|
754
|
+
tr.last_segments = [{"text": "how are you", "avg_logprob": -0.3}]
|
|
755
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
756
|
+
assert [seg["text"] for seg in s.final_segments] == [
|
|
757
|
+
"hello there friend", "how are you",
|
|
758
|
+
]
|
|
759
|
+
|
|
760
|
+
def test_abort_discards_frozen_text(self):
|
|
761
|
+
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
762
|
+
s._frozen_raw = "hello there friend"
|
|
763
|
+
s._frozen_samples = SR * 3
|
|
764
|
+
s._typed_text = "hello there friend"
|
|
765
|
+
rec.get_audio_so_far.return_value = np.concatenate([_silent(3), _loud(1.5)])
|
|
766
|
+
tr.transcribe.return_value = "scratch that"
|
|
767
|
+
with patch("voiceio.postprocess.apply_pipeline", return_value=("", True)):
|
|
768
|
+
s._transcribe_and_apply()
|
|
769
|
+
assert s._frozen_raw == ""
|
|
770
|
+
assert s._typed_text == ""
|
|
771
|
+
# Frozen boundary advances so the wiped audio is never re-decoded
|
|
772
|
+
assert s._frozen_samples == SR * 4.5
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class TestDecodeTrace:
|
|
776
|
+
def test_trace_records_pass_kinds(self):
|
|
777
|
+
recorder = MagicMock()
|
|
778
|
+
recorder.sample_rate = SR
|
|
779
|
+
transcriber = MagicMock()
|
|
780
|
+
transcriber.last_segments = []
|
|
781
|
+
s = StreamingSession(
|
|
782
|
+
transcriber=transcriber,
|
|
783
|
+
typer=MagicMock(spec=TyperBackend),
|
|
784
|
+
recorder=recorder,
|
|
785
|
+
freeze_secs=2.0,
|
|
786
|
+
)
|
|
787
|
+
transcriber.transcribe.return_value = "hello there friend"
|
|
788
|
+
rec_audio = _silent(3)
|
|
789
|
+
recorder.get_audio_so_far.return_value = rec_audio
|
|
790
|
+
s._transcribe_and_apply() # freeze pass
|
|
791
|
+
s._final_audio = np.concatenate([_silent(3), _loud(1)])
|
|
792
|
+
transcriber.transcribe.return_value = "bye"
|
|
793
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
794
|
+
kinds = [p["kind"] for p in s.trace]
|
|
795
|
+
assert kinds == ["freeze", "final"]
|
|
796
|
+
assert s.trace[0]["text"] == "hello there friend"
|
|
797
|
+
assert s.trace[1]["tail_secs"] == 1.0
|
|
798
|
+
assert all("secs" in p for p in s.trace)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# --- code-review regressions: decode failures must never lose audio/text ---
|
|
802
|
+
|
|
803
|
+
class TestDecodeFailureSafety:
|
|
804
|
+
def _make_session(self, freeze_secs=2.0, typer_spec=TyperBackend):
|
|
805
|
+
recorder = MagicMock()
|
|
806
|
+
recorder.sample_rate = SR
|
|
807
|
+
transcriber = MagicMock()
|
|
808
|
+
transcriber.last_segments = []
|
|
809
|
+
s = StreamingSession(
|
|
810
|
+
transcriber=transcriber,
|
|
811
|
+
typer=MagicMock(spec=typer_spec),
|
|
812
|
+
recorder=recorder,
|
|
813
|
+
freeze_secs=freeze_secs,
|
|
814
|
+
)
|
|
815
|
+
return s, transcriber, recorder
|
|
816
|
+
|
|
817
|
+
def test_failed_freeze_decode_does_not_advance_boundary(self):
|
|
818
|
+
s, tr, rec = self._make_session()
|
|
819
|
+
rec.get_audio_so_far.return_value = _silent(3)
|
|
820
|
+
tr.transcribe.side_effect = RuntimeError("decode timed out")
|
|
821
|
+
s._transcribe_and_apply()
|
|
822
|
+
assert s._frozen_samples == 0 # audio NOT discarded
|
|
823
|
+
assert s._frozen_raw == ""
|
|
824
|
+
|
|
825
|
+
def test_failed_final_decode_commits_interim_text(self):
|
|
826
|
+
s, tr, rec = self._make_session(typer_spec=StreamingTyper)
|
|
827
|
+
s._frozen_raw = "hello there friend"
|
|
828
|
+
s._frozen_samples = SR * 3
|
|
829
|
+
s._typed_text = "hello there friend how are you" # shown via preedit
|
|
830
|
+
s._final_audio = np.concatenate([_silent(3), _loud(2)])
|
|
831
|
+
tr.transcribe.side_effect = RuntimeError("decode timed out")
|
|
832
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
833
|
+
# Interim text committed, not char-diffed down to frozen-only text
|
|
834
|
+
s._typer.commit_text.assert_called_once_with("hello there friend how are you")
|
|
835
|
+
assert s.final_latency.get("decode_failed") is True
|
|
836
|
+
|
|
837
|
+
def test_final_decodes_sub_300ms_tail(self):
|
|
838
|
+
s, tr, rec = self._make_session()
|
|
839
|
+
s._frozen_raw = "hello there friend"
|
|
840
|
+
s._frozen_samples = SR * 3
|
|
841
|
+
s._final_audio = np.concatenate([_silent(3), _loud(0.25)])
|
|
842
|
+
tr.transcribe.return_value = "yes"
|
|
843
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
844
|
+
tr.transcribe.assert_called_once() # short last word not dropped
|
|
845
|
+
assert s.raw_final_text == "hello there friend yes"
|
|
846
|
+
|
|
847
|
+
def test_empty_interim_decode_keeps_display(self):
|
|
848
|
+
s, tr, rec = self._make_session(freeze_secs=0)
|
|
849
|
+
s._frozen_raw = "hello there friend"
|
|
850
|
+
s._frozen_samples = SR * 3
|
|
851
|
+
s._typed_text = "hello there friend how are"
|
|
852
|
+
rec.get_audio_so_far.return_value = np.concatenate([_silent(3), _loud(1.5)])
|
|
853
|
+
tr.transcribe.return_value = "" # transient decoder flake
|
|
854
|
+
s._transcribe_and_apply()
|
|
855
|
+
s._typer.type_text.assert_not_called()
|
|
856
|
+
s._typer.delete_chars.assert_not_called()
|
|
857
|
+
assert s._typed_text == "hello there friend how are"
|
|
858
|
+
|
|
859
|
+
def test_quiet_mic_speech_is_not_silence(self):
|
|
860
|
+
from voiceio.streaming import _tail_ends_in_silence
|
|
861
|
+
# Continuous speech on a low-gain mic: quiet in absolute terms but
|
|
862
|
+
# uniform — trailing window matches overall level, so NOT a pause.
|
|
863
|
+
quiet_speech = np.full(SR * 3, 0.005, dtype=np.float32)
|
|
864
|
+
assert not _tail_ends_in_silence(quiet_speech, SR)
|
|
865
|
+
# Same quiet mic with a real trailing pause IS a pause.
|
|
866
|
+
with_pause = np.concatenate([
|
|
867
|
+
np.full(int(SR * 2.5), 0.005, dtype=np.float32), _silent(0.5),
|
|
868
|
+
])
|
|
869
|
+
assert _tail_ends_in_silence(with_pause, SR)
|
|
@@ -130,3 +130,39 @@ class TestRestartBudgetReset:
|
|
|
130
130
|
|
|
131
131
|
with pytest.raises(RuntimeError, match="crashed"):
|
|
132
132
|
t._ensure_worker()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestTimeoutRaises:
|
|
136
|
+
"""Timeout/invalid response raise TranscriptionError (never fake silence)."""
|
|
137
|
+
|
|
138
|
+
def _make(self):
|
|
139
|
+
from voiceio.transcriber import Transcriber
|
|
140
|
+
mock_proc = MagicMock()
|
|
141
|
+
mock_proc.stdout.readline.return_value = "READY\n"
|
|
142
|
+
mock_proc.poll.return_value = None
|
|
143
|
+
with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc):
|
|
144
|
+
t = Transcriber(ModelConfig())
|
|
145
|
+
return t, mock_proc
|
|
146
|
+
|
|
147
|
+
def test_timeout_raises_and_clears_segments(self):
|
|
148
|
+
import numpy as np
|
|
149
|
+
import pytest
|
|
150
|
+
from voiceio.transcriber import TranscriptionError
|
|
151
|
+
t, mock_proc = self._make()
|
|
152
|
+
t.last_segments = [{"text": "stale"}]
|
|
153
|
+
with patch.object(t, "_read_with_timeout", return_value=None), \
|
|
154
|
+
patch.object(t, "_kill_worker"), patch.object(t, "_ensure_worker"):
|
|
155
|
+
with pytest.raises(TranscriptionError):
|
|
156
|
+
t.transcribe(np.zeros(16000, dtype=np.float32))
|
|
157
|
+
assert t.last_segments == []
|
|
158
|
+
|
|
159
|
+
def test_invalid_response_raises_and_clears_segments(self):
|
|
160
|
+
import numpy as np
|
|
161
|
+
import pytest
|
|
162
|
+
from voiceio.transcriber import TranscriptionError
|
|
163
|
+
t, mock_proc = self._make()
|
|
164
|
+
t.last_segments = [{"text": "stale"}]
|
|
165
|
+
with patch.object(t, "_read_with_timeout", return_value="not json{"):
|
|
166
|
+
with pytest.raises(TranscriptionError):
|
|
167
|
+
t.transcribe(np.zeros(16000, dtype=np.float32))
|
|
168
|
+
assert t.last_segments == []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -393,6 +393,7 @@ class VoiceIO:
|
|
|
393
393
|
voice_input_prefix=self._voice_input_prefix,
|
|
394
394
|
on_typer_broken=self._on_typer_broken,
|
|
395
395
|
on_interim=self._on_interim_text,
|
|
396
|
+
freeze_secs=self.cfg.output.streaming_freeze_secs,
|
|
396
397
|
)
|
|
397
398
|
self._session.start()
|
|
398
399
|
log.info("Recording... press [%s] again to stop", self.cfg.hotkey.key)
|
|
@@ -545,6 +546,16 @@ class VoiceIO:
|
|
|
545
546
|
duration=elapsed,
|
|
546
547
|
extra=extra,
|
|
547
548
|
)
|
|
549
|
+
from voiceio import retention
|
|
550
|
+
retention.save_trace(self.cfg.data, {
|
|
551
|
+
"ts": time.time(),
|
|
552
|
+
"audio": extra.get("audio"),
|
|
553
|
+
"duration": round(elapsed, 2),
|
|
554
|
+
"latency": latency,
|
|
555
|
+
# Snapshot: a timed-out worker join means the thread may
|
|
556
|
+
# still be appending while we serialize.
|
|
557
|
+
"passes": list(session.trace),
|
|
558
|
+
})
|
|
548
559
|
log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
|
|
549
560
|
# Release the IBus input source now that the final commit is done.
|
|
550
561
|
# Generation-checked inside: if a newer recording started, it already
|
|
@@ -33,6 +33,11 @@ PID_PATH = LOG_DIR / "voiceio.pid"
|
|
|
33
33
|
# and the retired-rule state consulted by the mining side.
|
|
34
34
|
CORRECTIONS_AUDIT_PATH = LOG_DIR / "corrections_audit.jsonl"
|
|
35
35
|
METRICS_PATH = LOG_DIR / "metrics.jsonl"
|
|
36
|
+
# Intermediate pipeline data kept for debugging/profiling and future training:
|
|
37
|
+
# per-pass streaming decode traces and postcorrect before/after pairs. These
|
|
38
|
+
# live in their own JSONL files (not the rotating log) so they survive.
|
|
39
|
+
TRACES_PATH = LOG_DIR / "streaming_trace.jsonl"
|
|
40
|
+
POSTCORRECT_PAIRS_PATH = LOG_DIR / "postcorrect_pairs.jsonl"
|
|
36
41
|
SNAPSHOTS_DIR = CONFIG_DIR / "snapshots"
|
|
37
42
|
AUDIT_STATE_PATH = CONFIG_DIR / "audit_state.json"
|
|
38
43
|
AUTOCORRECT_STATE_PATH = CONFIG_DIR / "autocorrect_state.json"
|
|
@@ -79,6 +84,11 @@ class OutputConfig:
|
|
|
79
84
|
punctuation_cleanup: bool = True
|
|
80
85
|
number_conversion: bool = True
|
|
81
86
|
voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
|
|
87
|
+
# Incremental finalization: once the un-finalized audio tail grows past
|
|
88
|
+
# this many seconds AND ends in silence, beam-decode and freeze it during
|
|
89
|
+
# recording, so interim passes and the stop-time final decode only cover
|
|
90
|
+
# the short remaining tail. 0 disables (full re-decode at stop).
|
|
91
|
+
streaming_freeze_secs: float = 25.0
|
|
82
92
|
# Mirror transcribed text to the system clipboard so it can be pasted:
|
|
83
93
|
# "off" — never
|
|
84
94
|
# "final" — the corrected final text, once ready
|
|
@@ -179,7 +189,14 @@ class DataConfig:
|
|
|
179
189
|
"""
|
|
180
190
|
retain_audio: bool = True
|
|
181
191
|
max_audio_mb: int = 4096 # prune oldest recordings beyond this
|
|
192
|
+
# Never let retention squeeze the disk: skip saving and prune harder
|
|
193
|
+
# when the filesystem has less than this much free space.
|
|
194
|
+
min_free_gb: float = 5.0
|
|
182
195
|
capture_context: bool = True # best-effort active-window title
|
|
196
|
+
# Per-pass streaming decode traces + postcorrect before/after pairs
|
|
197
|
+
# (streaming_trace.jsonl / postcorrect_pairs.jsonl) for debugging,
|
|
198
|
+
# profiling, and future fine-tuning.
|
|
199
|
+
capture_intermediates: bool = True
|
|
183
200
|
|
|
184
201
|
|
|
185
202
|
@dataclass
|
|
@@ -283,6 +300,8 @@ def _content_files() -> list[Path]:
|
|
|
283
300
|
HISTORY_PATH,
|
|
284
301
|
CORRECTIONS_AUDIT_PATH,
|
|
285
302
|
METRICS_PATH,
|
|
303
|
+
TRACES_PATH,
|
|
304
|
+
POSTCORRECT_PAIRS_PATH,
|
|
286
305
|
AUDIT_STATE_PATH,
|
|
287
306
|
AUTOCORRECT_STATE_PATH,
|
|
288
307
|
CONSENT_PATH,
|