Files
Bolt-Pet/bolt_pet/doctor.py
T

286 lines
12 KiB
Python

"""`python -m bolt_pet --doctor` — is this install actually going to work?
Written after a week of debugging things a preflight would have shown in one
command: a missing port publish, a wrong reverse-proxy header, a venv whose
python was a zero-byte file, an OCR engine that was never installed. Every one
of those presented as "the pet is being weird" and took a conversation to find.
Each check is independent and reports one of three things — ok, a warning
(works, but degraded), or a failure (this will not do what you expect) — and
says *what to do about it* rather than just what is wrong. Nothing here raises:
a doctor that crashes on a broken install is diagnosing the wrong patient.
Deliberately does not open the mic or call a paid API by default. It checks
that the door is unlocked, not that the room is furnished; `--deep` is there
when you want it to actually knock.
"""
from __future__ import annotations
import importlib
import shutil
import sys
from dataclasses import dataclass
from typing import Callable, Optional
from . import config
OK, WARN, FAIL = "ok", "warn", "fail"
_MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}
@dataclass
class Check:
name: str
status: str
detail: str = ""
fix: str = ""
def line(self) -> str:
text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
if self.fix and self.status != OK:
text += f"\n{'':32}{self.fix}"
return text
def _module(name: str) -> bool:
try:
importlib.import_module(name)
return True
except Exception:
return False
# ── the checks ──────────────────────────────────────────────────────────────
def check_config() -> Check:
missing = config.missing_config()
if missing:
return Check("server config", FAIL, f"missing {', '.join(missing)}",
"set them in .env — without these the controller exits at startup")
return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")
def check_server(deep: bool = False) -> Check:
if not config.SERVER_URL:
return Check("server", FAIL, "no BOLT_SERVER_URL",
"cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
if not deep:
return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
try:
from . import server_client
health = server_client.check_health(timeout=8)
return Check("server", OK, f"reachable — {health}")
except Exception as exc:
return Check("server", FAIL, f"unreachable: {exc}",
"check the URL, the key, and that the container is up")
def check_streaming_endpoint(deep: bool = False) -> Check:
"""The streamed-reply endpoint is newer than some deployed servers."""
if not config.STREAMING_REPLIES:
return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
if not deep:
return Check("streamed replies", OK, "enabled (endpoint not probed)")
try:
import requests
response = requests.post(
f"{config.SERVER_URL}/desk/converse_stream",
json={"session_id": config.SESSION_ID, "text": ""},
headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
)
if response.status_code == 404:
return Check("streamed replies", WARN, "server has no /desk/converse_stream",
"update the server, or set STREAMING_REPLIES=false to skip the probe")
return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
except Exception as exc:
return Check("streamed replies", WARN, f"probe failed: {exc}")
def check_microphone(deep: bool = False) -> Check:
if not _module("sounddevice"):
return Check("microphone", FAIL, "sounddevice is not installed",
"pip install -r requirements.txt")
try:
import sounddevice as sd
devices = [d for d in sd.query_devices() if d.get("max_input_channels", 0) > 0]
if not devices:
return Check("microphone", FAIL, "no input devices",
"on Linux check PipeWire/PulseAudio is running as your user")
chosen = config.MIC_DEVICE or "system default"
if not deep:
return Check("microphone", OK, f"{len(devices)} input device(s), using {chosen}")
with sd.InputStream(samplerate=config.SAMPLE_RATE, channels=1, dtype="int16",
device=config.MIC_DEVICE, blocksize=config.FRAME_LEN):
pass
return Check("microphone", OK, f"opened at {config.SAMPLE_RATE} Hz ({chosen})")
except Exception as exc:
return Check("microphone", FAIL, f"could not open: {exc}",
"don't run the pet as root — PortAudio can't reach your PipeWire socket")
def check_wake_model() -> Check:
from pathlib import Path
path = Path(config.WAKE_MODEL_PATH)
if not path.exists():
return Check("wake word", FAIL, f"{path.name} is missing",
"it ships in the project root; check WAKE_MODEL_FILE")
if not _module("openwakeword"):
return Check("wake word", FAIL, "openwakeword is not installed",
"pip install -r requirements.txt")
return Check("wake word", OK, f"{path.name}, threshold {config.WAKE_WORD_THRESHOLD}")
def check_stt() -> Check:
"""STT is a websocket relay to the server (/desk/stt) — no local Deepgram
account, but websocket-client is now load-bearing for transcription to
work at all, not just the streaming optimisation (there's no separate
REST fallback any more)."""
if not config.is_configured():
return Check("speech-to-text", FAIL, "no BOLT_SERVER_URL/DESK_API_KEY",
"set them in .env — nothing you say can be transcribed without the server")
if not _module("websocket"):
return Check("speech-to-text", FAIL, "websocket-client is missing",
"pip install -r requirements.txt — /desk/stt is a websocket relay "
"with no REST fallback")
mode = "streaming" if config.STT_STREAMING else "one-shot (still via the server relay)"
return Check("speech-to-text", OK, f"server relay, {mode}")
def check_tts() -> Check:
"""TTS is the server's /desk/tts — no local ElevenLabs account needed for
the normal reply voice, just a voice id for it to request."""
if config.is_configured() and config.ELEVENLABS_VOICE_ID:
return Check("text-to-speech", OK,
f"server relay, voice …{config.ELEVENLABS_VOICE_ID[-6:]}")
if _module("pyttsx3"):
return Check("text-to-speech", WARN, "server/voice not configured — offline voice only",
"set BOLT_SERVER_URL/DESK_API_KEY and ELEVENLABS_VOICE_ID for the real voice")
return Check("text-to-speech", FAIL, "no server/voice config and no pyttsx3 fallback",
"set BOLT_SERVER_URL/DESK_API_KEY/ELEVENLABS_VOICE_ID, "
"or pip install pyttsx3 for an offline voice")
def check_dialogue() -> Check:
if not config.DIALOGUE:
return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)")
if not config.ELEVENLABS_API_KEY:
return Check("multi-voice scenes", WARN, "no ELEVENLABS_API_KEY",
"set it in .env — dialogue scenes are the one feature still calling "
"ElevenLabs directly, since the server has no equivalent endpoint")
from . import dialogue
cast = dialogue.parse_voice_map(config.DIALOGUE_VOICES)
if not cast:
return Check("multi-voice scenes", WARN, "no cast configured — only 'self' works",
'set DIALOGUE_VOICES=narrator:<id>,villain:<id>')
return Check("multi-voice scenes", OK, f"{len(cast)} voice(s): {', '.join(sorted(cast))}")
def check_screen_text() -> Check:
if not config.SCREEN_TEXT:
return Check("screen reading", WARN, "disabled (SCREEN_TEXT=false)")
if not _module("mss"):
return Check("screen reading", WARN, "mss is not installed — petctl read will decline",
"pip install mss (and note it cannot capture on Wayland)")
engine = "pytesseract" if _module("pytesseract") else (
"rapidocr" if _module("rapidocr_onnxruntime") else "")
if not engine:
return Check("screen reading", WARN, "no OCR engine",
"pip install pytesseract && apt install tesseract-ocr, "
"or pip install rapidocr-onnxruntime")
if engine == "pytesseract" and not shutil.which("tesseract"):
return Check("screen reading", WARN, "pytesseract is installed but tesseract is not",
"apt install tesseract-ocr")
return Check("screen reading", OK, f"mss + {engine}")
def check_hotkey() -> Check:
if not _module("pynput"):
return Check("push-to-talk", WARN, "pynput is not installed",
"the wake word still works; pip install pynput for the hotkey")
import os
if os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY"):
return Check("push-to-talk", WARN, "Wayland session — global hotkeys usually blocked",
"use the wake word, or click the pet")
return Check("push-to-talk", OK, config.PUSH_TO_TALK_HOTKEY)
def check_sprites() -> Check:
from pathlib import Path
root = Path(__file__).resolve().parent / "assets" / "sprites"
if not root.exists():
return Check("sprites", WARN, "no art — the placeholder blob will be drawn",
"python scripts/generate_bolt_sprites.py")
counts = {d.name: len(list(d.glob("*.png"))) for d in sorted(root.iterdir()) if d.is_dir()}
empty = [name for name, count in counts.items() if count == 0]
if empty:
return Check("sprites", WARN, f"no frames for: {', '.join(empty)}",
"python scripts/generate_bolt_sprites.py")
return Check("sprites", OK, ", ".join(f"{n} {c}" for n, c in counts.items()))
def check_latency() -> Check:
"""The setting most likely to make it feel slow, and the least obvious."""
silence = config.SILENCE_END_SEC
if silence >= 1.5:
return Check("turn latency", WARN, f"VAD_SILENCE_END_SEC={silence:g}s of dead air per turn",
"0.8-1.0 feels markedly snappier; it is pure wait before anything starts")
return Check("turn latency", OK, f"silence timeout {silence:g}s, "
f"streaming {'on' if config.STREAMING_REPLIES else 'off'}")
CHECKS: tuple[tuple[str, Callable], ...] = (
("config", check_config),
("server", check_server),
("stream", check_streaming_endpoint),
("mic", check_microphone),
("wake", check_wake_model),
("stt", check_stt),
("tts", check_tts),
("dialogue", check_dialogue),
("screen", check_screen_text),
("hotkey", check_hotkey),
("sprites", check_sprites),
("latency", check_latency),
)
def run(deep: bool = False) -> list[Check]:
results = []
for _name, check in CHECKS:
try:
try:
results.append(check(deep))
except TypeError:
results.append(check())
except Exception as exc: # a broken check must not hide the others
results.append(Check(_name, FAIL, f"the check itself failed: {exc}"))
return results
def main(argv: Optional[list] = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
deep = "--deep" in argv
print(f"Bolt pet preflight{' (deep: contacting the server and opening the mic)' if deep else ''}\n")
results = run(deep=deep)
for check in results:
print(check.line())
failures = [c for c in results if c.status == FAIL]
warnings = [c for c in results if c.status == WARN]
print()
if failures:
print(f"{len(failures)} problem(s) will stop this working. Fix those first.")
elif warnings:
print(f"Ready. {len(warnings)} thing(s) degraded but working.")
else:
print("Everything checks out.")
return 1 if failures else 0