atlas-iac/services/hermes/scripts/hermes_stt_client.py
jenkins 546e960185 hermes(stt-client): retry a brief STT outage, never dump a traceback
A transcription that landed while the private Whisper service was
restarting (an image roll) crashed hermes_stt_client.py with a raw
urllib ConnectionRefused traceback that got dumped into the
conversation. The client now retries the request with backoff (up to 5
attempts, ~10s - long enough to ride an STT pod restart) and, on a
persistent outage, exits with one concise line ('speech transcription
unavailable...') instead of a stack trace. Delivered via the coordinator
ConfigMap; the next reconcile picks it up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 20:16:57 -03:00

143 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Send one Hermes local-command STT request to the private Whisper service."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import secrets
import sys
import time
from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen
# The private Whisper service briefly restarts on image rolls; a transcription
# request that lands in that window must retry rather than crash the turn.
_MAX_ATTEMPTS = 5
_RETRY_BACKOFF_SECONDS = (0.5, 1.5, 3.0, 5.0)
LANGUAGE_SUFFIX = ".language"
def _normalize_language(value: object) -> str:
"""Accept only a bare ISO-639 code from the private Whisper response."""
if not isinstance(value, str):
return ""
code = value.strip().lower()
if not 2 <= len(code) <= 3 or not code.isascii() or not code.isalpha():
return ""
return code
def _write_result(output_dir: Path, stem: str, transcript: str, language: str) -> Path:
"""Write the .txt Hermes reads, plus the language sidecar when we have one.
Hermes' local-command contract is "leave a .txt in --output-dir"; it globs
``*.txt`` and reads the first match. The sidecar deliberately uses another
suffix so the transcript stays the only ``.txt`` in the directory.
"""
output_dir.mkdir(parents=True, exist_ok=True)
transcript_path = output_dir / f"{stem}.txt"
transcript_path.write_text(transcript, encoding="utf-8")
if language:
(output_dir / f"{stem}{LANGUAGE_SUFFIX}").write_text(language, encoding="utf-8")
return transcript_path
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
chunks: list[bytes] = []
def field(name: str, value: str) -> None:
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
]
)
field("language", language)
field("model", model)
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{audio.name}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
audio.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
]
)
return b"".join(chunks), boundary
def _transcribe_with_retry(request: Request) -> dict:
"""POST to the Whisper service, retrying a brief outage (a service roll).
A connection error means the private STT service is momentarily down
(e.g. rolling to a new image); retry with backoff instead of surfacing a
Python traceback into the conversation. On exhaustion, raise a plain
RuntimeError so ``main`` can print one short line, not a stack trace.
"""
last_error: Exception | None = None
for attempt in range(_MAX_ATTEMPTS):
try:
with urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except (URLError, ConnectionError, TimeoutError, OSError) as error:
last_error = error
if attempt < _MAX_ATTEMPTS - 1:
delay = _RETRY_BACKOFF_SECONDS[min(attempt, len(_RETRY_BACKOFF_SECONDS) - 1)]
time.sleep(delay)
raise RuntimeError(
"the speech service is temporarily unavailable; please try again"
) from last_error
def main() -> None:
"""Transcribe one file and emit the .txt contract Hermes expects."""
parser = argparse.ArgumentParser()
parser.add_argument("input_path", type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--language", default="auto")
parser.add_argument("--model", default="small")
args = parser.parse_args()
body, boundary = _multipart(args.input_path, args.language, args.model)
request = Request(
os.getenv(
"HERMES_STT_URL",
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions",
),
data=body,
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
},
method="POST",
)
result = _transcribe_with_retry(request)
transcript = str(result.get("text") or "").strip()
_write_result(
args.output_dir,
args.input_path.stem,
transcript,
_normalize_language(result.get("language")),
)
if __name__ == "__main__":
try:
main()
except RuntimeError as error:
# A concise message, never a traceback, reaches the conversation.
print(f"speech transcription unavailable: {error}", file=sys.stderr)
sys.exit(1)