P0 — Refinement blocked every dictation final with no timeout. With refinement auto:true and a slow/dead LLM endpoint, maybe_refine ran unbounded and blocked the final send in all three capture_ws handlers (~51s measured; the pill hung "Transcribing…" until the widget's 15s fallback fired). Fix the class: a hard, env-tunable budget (OMNIVOICE_REFINE_TIMEOUT_S, default 4s) via a new maybe_refine_async — a slow/dead endpoint now falls back to the unrefined (but polished) text within the budget and can NEVER delay the final beyond it. The LLM HTTP call is bounded to the same budget so the orphaned worker unwinds instead of holding a connection for the client's full 45s. Refinement is now also fully best-effort in the legacy handler (it can't turn a good final into an error frame). P1 — REST /transcribe lacked polish parity. capture.py never applied polish_text, so REST returned raw "…test" while the WS returned "…test." Apply text_polish.polish_text to `text` and `refined_text` (segments stay raw), so the widget POST fallback and MCP/CLI callers match the live socket. P1 — The #888 "instant first dictation" preload was a no-op. The preload called warmup() only `if hasattr`, but SherpaDictationBackend had none, and the WS handlers built a FRESH backend per session so a warm singleton wasn't reused. Add SherpaDictationBackend.warmup() (builds the recognizer) and share one warm recognizer per model id across sessions (get_sherpa_dictation_backend, same invalidation + a shared lock as the capture singleton); each session keeps its own decode stream. First dictation no longer pays the 1.3–2.5s load. P1 — llm_ready is a lie (feeds the P0). It only means "an endpoint is configured", so a placeholder key reads as ready. The P0 timeout makes a dead endpoint harmless; add last_refine_status so RefinementPanel flags a configured-but-failing LLM and links to LLM Providers → Test. Regression tests (fail-before/pass-after): slow-LLM WS final arrives < budget; maybe_refine_async hard timeout + status; REST polish parity + refined_text polish; warmup builds the recognizer and a second session reuses it; the panel honesty note. Backend refinement/capture_ws/capture/sherpa suites, CJK + route inventory gates, full vitest (733), lint (0 errors) and format all green. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
231 lines
9.2 KiB
Python
231 lines
9.2 KiB
Python
"""Phase-2 dictation refinement (Wave 2.1) — prompt builder + maybe_refine.
|
|
|
|
No real LLM: the active backend is monkeypatched. The pass-through contract
|
|
(raw transcript stands on ANY failure) is the load-bearing behavior here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from services import refinement
|
|
from services.refinement import (
|
|
REFINEMENT_EXAMPLES,
|
|
RefinementFlags,
|
|
build_refinement_prompt,
|
|
)
|
|
|
|
|
|
# ── Prompt builder ──────────────────────────────────────────────────────────
|
|
|
|
def test_all_flags_on_includes_all_sections():
|
|
p = build_refinement_prompt(RefinementFlags())
|
|
assert "text filter, not an assistant" in p
|
|
assert "Remove disfluencies" in p
|
|
assert "changes their mind mid-utterance" in p
|
|
assert "Preserve technical terms" in p
|
|
|
|
|
|
def test_flags_off_drop_sections():
|
|
p = build_refinement_prompt(RefinementFlags(self_correction=False, preserve_technical=False))
|
|
assert "Remove disfluencies" in p
|
|
assert "changes their mind mid-utterance" not in p
|
|
assert "Preserve technical terms" not in p
|
|
|
|
|
|
def test_no_flags_yields_passthrough_prompt():
|
|
p = build_refinement_prompt(
|
|
RefinementFlags(smart_cleanup=False, self_correction=False, preserve_technical=False)
|
|
)
|
|
assert "Return the transcript unchanged" in p
|
|
|
|
|
|
def test_examples_are_user_assistant_pairs():
|
|
assert len(REFINEMENT_EXAMPLES) == 7
|
|
for user_turn, assistant_turn in REFINEMENT_EXAMPLES:
|
|
assert user_turn and assistant_turn
|
|
|
|
|
|
# ── refine_transcript message shape ─────────────────────────────────────────
|
|
|
|
class _FakeBackend:
|
|
id = "openai-compat"
|
|
|
|
def __init__(self, reply="Refined."):
|
|
self.reply = reply
|
|
self.seen_messages = None
|
|
|
|
def chat_messages(self, *, messages, timeout=None):
|
|
self.seen_messages = messages
|
|
if isinstance(self.reply, Exception):
|
|
raise self.reply
|
|
return self.reply
|
|
|
|
|
|
def test_refine_transcript_builds_structured_few_shot(monkeypatch):
|
|
fake = _FakeBackend(" Cleaned text. ")
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
|
|
out = refinement.refine_transcript("um hello there", RefinementFlags())
|
|
assert out == "Cleaned text."
|
|
|
|
msgs = fake.seen_messages
|
|
assert msgs[0]["role"] == "system"
|
|
# 7 example pairs as real chat turns, then the live transcript last.
|
|
assert len(msgs) == 1 + 2 * len(REFINEMENT_EXAMPLES) + 1
|
|
assert msgs[1]["role"] == "user" and msgs[2]["role"] == "assistant"
|
|
assert msgs[-1] == {"role": "user", "content": "um hello there"}
|
|
|
|
|
|
# ── maybe_refine pass-through contract ──────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def stored_config(monkeypatch):
|
|
"""In-memory settings_store so config round-trips without SQLite."""
|
|
store = {}
|
|
monkeypatch.setattr("services.settings_store.get_text",
|
|
lambda key, default=None: store.get(key, default))
|
|
monkeypatch.setattr("services.settings_store.set_text",
|
|
lambda key, value: store.__setitem__(key, value))
|
|
return store
|
|
|
|
|
|
def test_maybe_refine_off_backend_returns_none(monkeypatch, stored_config):
|
|
class _Off:
|
|
id = "off"
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: _Off())
|
|
assert refinement.maybe_refine("some words here") is None
|
|
|
|
|
|
def test_maybe_refine_disabled_config_returns_none(monkeypatch, stored_config):
|
|
refinement.set_refinement_config({"auto": False})
|
|
fake = _FakeBackend("never called")
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
assert refinement.maybe_refine("some words here") is None
|
|
assert fake.seen_messages is None
|
|
|
|
|
|
def test_maybe_refine_llm_failure_returns_none(monkeypatch, stored_config):
|
|
fake = _FakeBackend(RuntimeError("connection refused"))
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
assert refinement.maybe_refine("some words here") is None
|
|
|
|
|
|
def test_maybe_refine_empty_reply_returns_none(monkeypatch, stored_config):
|
|
fake = _FakeBackend(" ")
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
assert refinement.maybe_refine("some words here") is None
|
|
|
|
|
|
def test_maybe_refine_success(monkeypatch, stored_config):
|
|
fake = _FakeBackend("So the meeting is at 3pm on Tuesday.")
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
out = refinement.maybe_refine("so um the meeting is at 3pm you know on tuesday")
|
|
assert out == "So the meeting is at 3pm on Tuesday."
|
|
|
|
|
|
def test_maybe_refine_empty_transcript_short_circuits(stored_config):
|
|
assert refinement.maybe_refine("") is None
|
|
assert refinement.maybe_refine(" ") is None
|
|
|
|
|
|
def test_maybe_refine_respects_flag_config(monkeypatch, stored_config):
|
|
refinement.set_refinement_config({"preserve_technical": False})
|
|
fake = _FakeBackend("ok")
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
refinement.maybe_refine("hello world out there")
|
|
assert "Preserve technical terms" not in fake.seen_messages[0]["content"]
|
|
|
|
|
|
# ── maybe_refine_async: hard timeout budget (P0 — 51s stall) ─────────────────
|
|
|
|
|
|
class _SlowBackend:
|
|
"""A live-but-unresponsive LLM: accepts the call, never answers in time —
|
|
the class of endpoint (placeholder key, dead Ollama) that stalled dictation."""
|
|
|
|
id = "openai-compat"
|
|
|
|
def __init__(self, sleep_s=5.0):
|
|
self.sleep_s = sleep_s
|
|
|
|
def chat_messages(self, *, messages, timeout=None):
|
|
time.sleep(self.sleep_s)
|
|
return "too late"
|
|
|
|
|
|
def test_refine_timeout_env_default_and_override(monkeypatch):
|
|
monkeypatch.delenv("OMNIVOICE_REFINE_TIMEOUT_S", raising=False)
|
|
assert refinement._refine_timeout_s() == 4.0
|
|
monkeypatch.setenv("OMNIVOICE_REFINE_TIMEOUT_S", "1.5")
|
|
assert refinement._refine_timeout_s() == 1.5
|
|
# Invalid / non-positive values can never disable the bound.
|
|
monkeypatch.setenv("OMNIVOICE_REFINE_TIMEOUT_S", "junk")
|
|
assert refinement._refine_timeout_s() == 4.0
|
|
monkeypatch.setenv("OMNIVOICE_REFINE_TIMEOUT_S", "-3")
|
|
assert refinement._refine_timeout_s() == 4.0
|
|
|
|
|
|
def test_maybe_refine_async_hard_timeout_returns_none_fast(monkeypatch, stored_config):
|
|
"""A slow LLM (5s) must NOT block past the 0.3s budget — the raw text stands
|
|
and the outcome is recorded as a timeout. Fail-before: the WS handler used
|
|
to `await asyncio.to_thread(maybe_refine, ...)` unbounded (the ~51s stall)."""
|
|
monkeypatch.setattr(
|
|
"services.llm_backend.get_active_llm_backend", lambda: _SlowBackend(3.0))
|
|
|
|
async def _timed():
|
|
# Measure the AWAIT inside the loop — the caller (the WS handler) is
|
|
# unblocked here, and the status is read at the instant dictation
|
|
# completes (before the orphaned to_thread finishes at loop shutdown;
|
|
# the long-lived app loop never waits on it).
|
|
t0 = time.perf_counter()
|
|
out = await refinement.maybe_refine_async("um hello there", timeout_s=0.3)
|
|
return out, time.perf_counter() - t0, refinement.get_last_refine_status()
|
|
|
|
out, dt, status = asyncio.run(_timed())
|
|
assert out is None
|
|
assert dt < 2.0, f"refinement blocked the caller {dt:.1f}s — the budget was 0.3s"
|
|
assert status and status["ok"] is False and status["reason"] == "timeout"
|
|
|
|
|
|
def test_maybe_refine_async_success_records_ok(monkeypatch, stored_config):
|
|
fake = _FakeBackend("So the meeting is at 3pm.")
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: fake)
|
|
|
|
out = asyncio.run(refinement.maybe_refine_async("so um the meeting is at 3pm"))
|
|
assert out == "So the meeting is at 3pm."
|
|
status = refinement.get_last_refine_status()
|
|
assert status and status["ok"] is True
|
|
|
|
|
|
def test_maybe_refine_async_off_backend_is_noop(monkeypatch, stored_config):
|
|
class _Off:
|
|
id = "off"
|
|
monkeypatch.setattr("services.llm_backend.get_active_llm_backend", lambda: _Off())
|
|
assert asyncio.run(refinement.maybe_refine_async("some words here")) is None
|
|
|
|
|
|
def test_maybe_refine_async_empty_transcript(stored_config):
|
|
assert asyncio.run(refinement.maybe_refine_async("")) is None
|
|
assert asyncio.run(refinement.maybe_refine_async(" ")) is None
|
|
|
|
|
|
# ── Config round-trip ───────────────────────────────────────────────────────
|
|
|
|
def test_config_roundtrip_and_unknown_keys_ignored(stored_config):
|
|
out = refinement.set_refinement_config({"self_correction": False, "bogus": True})
|
|
assert out["self_correction"] is False
|
|
assert "bogus" not in out
|
|
again = refinement.get_refinement_config()
|
|
assert again["self_correction"] is False
|
|
assert again["auto"] is True
|
|
|
|
|
|
def test_config_invalid_json_falls_back_to_defaults(stored_config):
|
|
stored_config[refinement._SETTINGS_KEY] = "{not json"
|
|
cfg = refinement.get_refinement_config()
|
|
assert cfg["auto"] is True and cfg["smart_cleanup"] is True
|