llm: catch all exceptions, validate response, show transcription preview (v1.9.9)

- llm.py: catch OSError/TimeoutError (socket.timeout is NOT a URLError subclass)
  and bare Exception so no path kills the background thread silently
- daemon.py: catch unexpected exceptions from llm.chat and surface them as
  overlay errors instead of silent thread death
- daemon.py: reject LLM responses that are >80 % whitespace (cold-start
  model returning spaces/blank lines gets discarded, not typed)
- daemon.py: show transcribed text in overlay immediately before thinking
  animation so user can see what was heard while LLM processes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-09 15:02:45 +02:00
parent 749ef78383
commit 0875fe7ac0
2 changed files with 31 additions and 0 deletions

View File

@ -510,6 +510,9 @@ class Daemon:
return return
self._emit("busy", label, "Rewriting…") self._emit("busy", label, "Rewriting…")
self._dnotify(f"{label}", "Rewriting…") self._dnotify(f"{label}", "Rewriting…")
# Show the transcribed text immediately so the user sees what was heard.
if self.text_cb:
self.text_cb(f"📝 {text}")
# Stream the rewrite into the overlay so you watch the model write # Stream the rewrite into the overlay so you watch the model write
# (the bubble updates token-by-token). The delivered text is still # (the bubble updates token-by-token). The delivered text is still
# the complete result, typed once the rewrite finishes. # the complete result, typed once the rewrite finishes.
@ -559,6 +562,27 @@ class Daemon:
self._emit("error", label, str(exc)) self._emit("error", label, str(exc))
self._dnotify("Rewrite failed", str(exc), "critical") self._dnotify("Rewrite failed", str(exc), "critical")
log(f"ERROR ({label} rewrite): {exc}") log(f"ERROR ({label} rewrite): {exc}")
if self.text_cb:
self.text_cb(f"{exc}")
return
except Exception as exc: # noqa: BLE001 - guard against any uncaught error
msg = f"LLM error: {exc}"
self._emit("error", label, msg)
log(f"ERROR ({label} rewrite unexpected): {exc}")
if self.text_cb:
self.text_cb(f"{msg}")
return
# Sanity-check: reject responses that are >80 % whitespace —
# a model that's cold-starting or misconfigured sometimes streams
# spaces or blank lines instead of real output.
non_ws = sum(1 for c in text if not c.isspace())
if non_ws < max(1, len(text) * 0.20):
msg = "LLM returned mostly whitespace — discarded"
self._emit("error", label, msg)
log(f"ERROR ({label}): {msg} (len={len(text)})")
if self.text_cb:
self.text_cb(f"{msg}")
return return
if not text: if not text:

View File

@ -93,6 +93,13 @@ def chat(
raise LLMError(f"Connection failed: {exc.reason}") from exc raise LLMError(f"Connection failed: {exc.reason}") from exc
except (KeyError, IndexError, TypeError) as exc: except (KeyError, IndexError, TypeError) as exc:
raise LLMError(f"Unexpected response: {exc}") from exc raise LLMError(f"Unexpected response: {exc}") from exc
except (TimeoutError, OSError) as exc:
# socket.timeout (subclass of OSError / TimeoutError) fires when the server
# stops sending data mid-stream. Not wrapped in URLError — must be caught
# separately or it would propagate uncaught and kill the background thread.
raise LLMError(f"Request timed out or connection lost: {exc}") from exc
except Exception as exc: # noqa: BLE001
raise LLMError(f"Unexpected error: {exc}") from exc
content = (content or "").strip() content = (content or "").strip()
if not content: if not content: