Merge branch 'main' into hermes/t_65356568-multiuser-capacity-assessment
Some checks failed
Tests / Declarative: Post Actions failed: 41, skipped: 19, passed: 2740
Some checks failed
Tests / Declarative: Post Actions failed: 41, skipped: 19, passed: 2740
This commit is contained in:
commit
8ce45159de
@ -1526,7 +1526,7 @@ function replaceOnce(source, before, after, label) {
|
||||
it to the shared Hermes bot from your Telegram account.
|
||||
</p>
|
||||
<a
|
||||
href="https://chat.hermes.bstein.dev/telegram"
|
||||
href="https://chat.bstein.dev/telegram"
|
||||
className="mt-4 inline-flex rounded border border-current/30 px-4 py-2 text-sm font-medium text-midground hover:bg-midground/10"
|
||||
>
|
||||
Open Telegram setup
|
||||
|
||||
@ -24,18 +24,41 @@ ADD --checksum=sha256:f7d01dde371555732c4c314111ac79672b1a5ce2fc19266ab42178fd8d
|
||||
ADD --checksum=sha256:45754dfdebb3b8661c3fc564713772deec6e064feeb5b4e9594857dc7305193a --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/low/en_US-lessac-low.onnx.json?download=true \
|
||||
/opt/models/piper/en_US-lessac-low.onnx.json
|
||||
|
||||
# Multilingual chat voice policy: English -> amy, Russian -> irina, Spanish ->
|
||||
# claude (Mexican Spanish, the only "claude" voice rhasspy/piper-voices
|
||||
# publishes; there is no es_ES-claude).
|
||||
ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18 --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true \
|
||||
/opt/models/piper/en_US-amy-medium.onnx
|
||||
ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77 --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx.json?download=true \
|
||||
/opt/models/piper/en_US-amy-medium.onnx.json
|
||||
ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx?download=true \
|
||||
/opt/models/piper/ru_RU-irina-medium.onnx
|
||||
ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7 --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx.json?download=true \
|
||||
/opt/models/piper/ru_RU-irina-medium.onnx.json
|
||||
ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0 --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx?download=true \
|
||||
/opt/models/piper/es_MX-claude-high.onnx
|
||||
ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx.json?download=true \
|
||||
/opt/models/piper/es_MX-claude-high.onnx.json
|
||||
RUN chmod 0555 /opt/models /opt/models/piper
|
||||
|
||||
COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py
|
||||
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py
|
||||
|
||||
# Load the actual pinned voice during the ARM64 build. This catches package or
|
||||
# model-format drift before the image can reach Flux.
|
||||
RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
|
||||
# Load every pinned voice during the ARM64 build, including the three baked
|
||||
# for the multilingual chat policy. This catches package or model-format
|
||||
# drift before the image can reach Flux.
|
||||
RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx',p/'en_US-amy-medium.onnx',p/'ru_RU-irina-medium.onnx',p/'es_MX-claude-high.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
|
||||
|
||||
ENV HERMES_TTS_HOST=0.0.0.0 \
|
||||
HERMES_TTS_PORT=9001 \
|
||||
HERMES_TTS_VOICE=en_US-lessac-medium \
|
||||
HERMES_TTS_VOICE=en_US-amy-medium \
|
||||
HERMES_TTS_CACHE=/opt/models/piper \
|
||||
OMP_NUM_THREADS=2 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
@ -90,11 +90,14 @@ PY
|
||||
# Add the Atlas voice bridge as a narrow integration layer. It activates only
|
||||
# when a tenant's server-side STT capability reports the private Jetson route.
|
||||
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
|
||||
COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py
|
||||
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
|
||||
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
|
||||
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
|
||||
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
|
||||
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
|
||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
|
||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py
|
||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py
|
||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py
|
||||
|
||||
@ -108,14 +111,24 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
||||
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
|
||||
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
|
||||
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
|
||||
&& grep -Fq 'Audio conversion failed: upload is invalid' /opt/hermes/tools/transcription_tools.py \
|
||||
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
|
||||
&& grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \
|
||||
&& grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \
|
||||
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
|
||||
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
||||
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
|
||||
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
||||
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
|
||||
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
|
||||
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
|
||||
&& grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
|
||||
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
|
||||
&& /opt/hermes/.venv/bin/python -m py_compile \
|
||||
/opt/hermes-webui/api/routes.py \
|
||||
/opt/hermes-webui/api/gateway_chat.py
|
||||
/opt/hermes-webui/api/upload.py \
|
||||
/opt/hermes-webui/api/gateway_chat.py \
|
||||
/opt/hermes/tools/transcription_tools.py
|
||||
|
||||
# Exercise the real server process in the target architecture before publish.
|
||||
RUN set -eu; \
|
||||
|
||||
@ -62,6 +62,34 @@ def _clean_transcript(result: dict) -> str:
|
||||
return " ".join(kept).strip()
|
||||
|
||||
|
||||
def _detected_language(result: object) -> str:
|
||||
"""Return the bare ISO-639 code Whisper decoded with, or nothing at all.
|
||||
|
||||
``whisper.transcribe`` reports the language it auto-detected (or the one it
|
||||
was told to use) as a plain lowercase token such as ``en``/``ru``/``yue``.
|
||||
Anything that is not that exact shape is dropped rather than guessed at, so
|
||||
a surprising model result can never become a downstream voice selector.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return ""
|
||||
value = result.get("language")
|
||||
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 _transcription_payload(result: dict) -> dict:
|
||||
"""Build the transcription contract: text plus the model's own language."""
|
||||
return {
|
||||
"text": _clean_transcript(result),
|
||||
"model": MODEL_NAME,
|
||||
"language": _detected_language(result),
|
||||
}
|
||||
|
||||
|
||||
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
@ -148,8 +176,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
no_speech_threshold=0.5,
|
||||
verbose=False,
|
||||
)
|
||||
transcript = _clean_transcript(result)
|
||||
_json(self, 200, {"text": transcript, "model": MODEL_NAME})
|
||||
_json(self, 200, _transcription_payload(result))
|
||||
except Exception as exc:
|
||||
print(f"[stt] transcription failed: {exc}", flush=True)
|
||||
_json(self, 500, {"error": "transcription failed"})
|
||||
|
||||
@ -17,12 +17,50 @@ from piper import PiperConfig, PiperVoice, SynthesisConfig
|
||||
|
||||
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
|
||||
VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high")
|
||||
CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper"))
|
||||
MAX_TEXT_CHARS = 5000
|
||||
ONNX_THREADS = max(1, int(os.getenv("HERMES_TTS_ONNX_THREADS", "4")))
|
||||
VOICE_LOCK = threading.Lock()
|
||||
|
||||
# Fixed, allow-listed language -> baked voice mapping. This is the ONLY path
|
||||
# from a client-supplied string to a model name: client input is looked up
|
||||
# here and never used to build a filesystem path directly. Both "-" and "_"
|
||||
# separators and any case are accepted; anything not present here falls back
|
||||
# to DEFAULT_VOICE_NAME (safe English default), never an error and never an
|
||||
# unbaked model.
|
||||
LANGUAGE_VOICE_MAP = {
|
||||
"en": "en_US-amy-medium",
|
||||
"en-us": "en_US-amy-medium",
|
||||
"ru": "ru_RU-irina-medium",
|
||||
"ru-ru": "ru_RU-irina-medium",
|
||||
"es": "es_MX-claude-high",
|
||||
"es-mx": "es_MX-claude-high",
|
||||
"es-es": "es_MX-claude-high",
|
||||
}
|
||||
BAKED_VOICE_NAMES = frozenset(LANGUAGE_VOICE_MAP.values())
|
||||
DEFAULT_VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-amy-medium")
|
||||
|
||||
|
||||
def normalize_language(value: object) -> str | None:
|
||||
"""Lowercase and fold "_"/"-" separators; reject non-string/blank input."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip().lower().replace("_", "-")
|
||||
return normalized or None
|
||||
|
||||
|
||||
def resolve_voice_name(language: object) -> str:
|
||||
"""Map a client-supplied language to one of the baked policy voices.
|
||||
|
||||
Unknown, missing, or malformed language always resolves to the safe
|
||||
default rather than raising, and the result is always a member of
|
||||
BAKED_VOICE_NAMES.
|
||||
"""
|
||||
normalized = normalize_language(language)
|
||||
if normalized is None:
|
||||
return DEFAULT_VOICE_NAME
|
||||
return LANGUAGE_VOICE_MAP.get(normalized, DEFAULT_VOICE_NAME)
|
||||
|
||||
|
||||
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
@ -46,7 +84,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
if self.path != "/health":
|
||||
_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
_json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"})
|
||||
_json(
|
||||
self,
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"voices": sorted(self.server.voices), # type: ignore[attr-defined]
|
||||
"default_voice": self.server.default_voice_name, # type: ignore[attr-defined]
|
||||
"device": "cpu",
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/audio/speech":
|
||||
@ -71,10 +118,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
speed = min(2.0, max(0.5, speed))
|
||||
|
||||
# Policy is driven ONLY by "language". A client-supplied "voice"
|
||||
# field is deliberately never read here; it cannot override the
|
||||
# allow-listed mapping.
|
||||
voice_name = resolve_voice_name(payload.get("language"))
|
||||
voice = self.server.voices[voice_name] # type: ignore[attr-defined]
|
||||
|
||||
output = io.BytesIO()
|
||||
try:
|
||||
with VOICE_LOCK, wave.open(output, "wb") as wav_file:
|
||||
self.server.voice.synthesize_wav( # type: ignore[attr-defined]
|
||||
voice.synthesize_wav(
|
||||
text,
|
||||
wav_file,
|
||||
SynthesisConfig(length_scale=1.0 / speed),
|
||||
@ -84,6 +137,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "audio/wav")
|
||||
self.send_header("Content-Length", str(len(audio)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-TTS-Voice", voice_name)
|
||||
self.end_headers()
|
||||
self.wfile.write(audio)
|
||||
except Exception as exc:
|
||||
@ -91,30 +145,54 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
_json(self, 500, {"error": "speech synthesis failed"})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load the checksum-pinned voice from the image and serve it on CPU."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
model_path = CACHE_DIR / f"{VOICE_NAME}.onnx"
|
||||
config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json"
|
||||
def _load_voice(cache_dir: Path, voice_name: str, threads: int) -> PiperVoice:
|
||||
model_path = cache_dir / f"{voice_name}.onnx"
|
||||
config_path = cache_dir / f"{voice_name}.onnx.json"
|
||||
if not model_path.exists() or not config_path.exists():
|
||||
raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}")
|
||||
raise RuntimeError(f"baked Piper voice is missing: {voice_name}")
|
||||
with config_path.open("r", encoding="utf-8") as config_file:
|
||||
config = PiperConfig.from_dict(json.load(config_file))
|
||||
session_options = onnxruntime.SessionOptions()
|
||||
session_options.intra_op_num_threads = ONNX_THREADS
|
||||
session_options.intra_op_num_threads = threads
|
||||
session_options.inter_op_num_threads = 1
|
||||
session = onnxruntime.InferenceSession(
|
||||
str(model_path),
|
||||
sess_options=session_options,
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
voice = PiperVoice(session=session, config=config, download_dir=CACHE_DIR)
|
||||
return PiperVoice(session=session, config=config, download_dir=cache_dir)
|
||||
|
||||
|
||||
def load_voices(cache_dir: Path, threads: int) -> dict[str, PiperVoice]:
|
||||
"""Eagerly load all three policy voices.
|
||||
|
||||
Preload (not lazy-load-on-first-use) was chosen deliberately: measured
|
||||
RSS on this model set is ~88MB for one voice and ~243MB for all three
|
||||
(~+155MB versus the previous single-voice baseline), which comfortably
|
||||
fits the pod's memory budget on the CPU-only voice node. Preloading
|
||||
avoids a slow, request-serializing first synthesis per language and
|
||||
keeps the fail-closed missing-model check (below) at process start
|
||||
rather than deferring a possible crash to a live user request.
|
||||
"""
|
||||
return {name: _load_voice(cache_dir, name, threads) for name in sorted(BAKED_VOICE_NAMES)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load the checksum-pinned policy voices from the image and serve them on CPU."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if DEFAULT_VOICE_NAME not in BAKED_VOICE_NAMES:
|
||||
raise RuntimeError(
|
||||
f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}"
|
||||
)
|
||||
voices = load_voices(CACHE_DIR, ONNX_THREADS)
|
||||
print(
|
||||
f"[tts] loaded Piper voice {VOICE_NAME} on CPU with {ONNX_THREADS} ONNX threads",
|
||||
f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: "
|
||||
+ ", ".join(sorted(voices)),
|
||||
flush=True,
|
||||
)
|
||||
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
|
||||
server.voice = voice # type: ignore[attr-defined]
|
||||
server.voices = voices # type: ignore[attr-defined]
|
||||
server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined]
|
||||
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@ def _fetch_public(url: str) -> tuple[str, str, str]:
|
||||
"""Fetch one public URL with redirect, size, MIME, and policy checks."""
|
||||
current = url
|
||||
headers = {
|
||||
"User-Agent": "HermesPrivateChat/1.0 (+https://chat.hermes.bstein.dev)",
|
||||
"User-Agent": "HermesPrivateChat/1.0 (+https://chat.bstein.dev)",
|
||||
"Accept": "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8",
|
||||
}
|
||||
with httpx.Client(follow_redirects=False, timeout=15.0, headers=headers) as client:
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path("/opt/hermes-webui")
|
||||
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
|
||||
# The WebUI imports the pinned agent's STT tooling from the same image, so the
|
||||
# local-command transcription envelope is patched alongside the WebUI itself.
|
||||
AGENT_ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
|
||||
|
||||
|
||||
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
|
||||
@ -15,24 +19,126 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
|
||||
path.write_text(source.replace(before, after, count), encoding="utf-8")
|
||||
|
||||
|
||||
def replace_between_exact(
|
||||
path: Path, start: str, end: str, after: str = "", count: int = 1
|
||||
) -> None:
|
||||
"""Replace one exact, bounded upstream region and fail when the pin drifts."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if source.count(start) != count or source.count(end) != count:
|
||||
raise SystemExit(
|
||||
f"Atlas voice patch context changed in {path}: {start[:80]!r}"
|
||||
)
|
||||
start_index = source.index(start)
|
||||
end_index = source.index(end, start_index) + len(end)
|
||||
path.write_text(
|
||||
source[:start_index] + after + source[end_index:], encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def assert_absent(path: Path, *needles: str) -> None:
|
||||
"""Fail the image build if a removed voice-choice surface remains."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
remaining = [needle for needle in needles if needle in source]
|
||||
if remaining:
|
||||
raise SystemExit(f"Atlas voice choice remains in {path}: {remaining!r}")
|
||||
|
||||
|
||||
def remove_lines_containing(path: Path, *needles: str) -> None:
|
||||
"""Remove all pinned translation entries for a retired settings control."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for needle in needles:
|
||||
if needle not in source:
|
||||
raise SystemExit(f"Atlas voice patch context changed in {path}: {needle!r}")
|
||||
lines = source.splitlines(keepends=True)
|
||||
path.write_text(
|
||||
"".join(line for line in lines if not any(n in line for n in needles)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
index = ROOT / "static/index.html"
|
||||
replace_exact(
|
||||
index,
|
||||
'<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">',
|
||||
'<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">\n'
|
||||
'<link id="voiceInstrumentStyles" rel="stylesheet" '
|
||||
'href="static/atlas-voice.css?v=__WEBUI_VERSION__">',
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
|
||||
'<option value="atlas">Atlas Jetson (private)</option><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
'''<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
||||
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="">Default system voice</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
|
||||
</div>''',
|
||||
"",
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>',
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n<script src="static/atlas-voice.js?v=__WEBUI_VERSION__" defer></script>',
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
''' <div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>''',
|
||||
''' <div class="voice-mode-bar" id="voiceModeBar" style="display:none" role="status" aria-live="polite" aria-atomic="true">
|
||||
<span class="voice-mode-indicator idle" id="voiceModeIndicator" aria-hidden="true">
|
||||
<span class="voice-instrument-halo"></span>
|
||||
<span class="voice-instrument-ripple"></span>
|
||||
<span class="voice-instrument-orbit"></span>
|
||||
<span class="voice-instrument-core">
|
||||
<span class="voice-instrument-symbol">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<g class="voice-symbol voice-symbol-listening"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v3M9 20h6"/></g>
|
||||
<g class="voice-symbol voice-symbol-transcribing"><path d="M5 7h14M5 12h10M5 17h7"/><path d="M18 15v5m-2.5-2.5L18 20l2.5-2.5"/></g>
|
||||
<g class="voice-symbol voice-symbol-thinking"><path d="M12 3l1.15 4.1L17 8.5l-3.85 1.4L12 14l-1.15-4.1L7 8.5l3.85-1.4L12 3Z"/><path d="M18.5 13.5l.65 2.35 2.35.65-2.35.65-.65 2.35-.65-2.35-2.35-.65 2.35-.65.65-2.35Z"/><path d="M5.5 14l.45 1.55L7.5 16l-1.55.45L5.5 18l-.45-1.55L3.5 16l1.55-.45L5.5 14Z"/></g>
|
||||
<g class="voice-symbol voice-symbol-speaking"><path d="M5 10v4h3l4 3V7L8 10H5Z"/><path d="M15.5 9.25a4 4 0 0 1 0 5.5M18 7a7 7 0 0 1 0 10"/></g>
|
||||
<g class="voice-symbol voice-symbol-error"><path d="M12 4 21 20H3L12 4Z"/><path d="M12 9v5M12 17.2v.1"/></g>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>''',
|
||||
)
|
||||
|
||||
ui = ROOT / "static/ui.js"
|
||||
replace_exact(
|
||||
ui,
|
||||
''' const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||||
const voices=speechSynthesis.getVoices();
|
||||
if(savedVoice&&voices.length){
|
||||
const match=voices.find(v=>v.name===savedVoice);
|
||||
if(match) utter.voice=match;
|
||||
}
|
||||
''',
|
||||
"",
|
||||
)
|
||||
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
|
||||
replace_exact(
|
||||
ui,
|
||||
" const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';\n",
|
||||
"",
|
||||
)
|
||||
replace_exact(
|
||||
ui,
|
||||
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
|
||||
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
|
||||
"body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
|
||||
)
|
||||
replace_exact(
|
||||
ui,
|
||||
" voice: localStorage.getItem('hermes-tts-voice')||'',\n",
|
||||
"",
|
||||
count=2,
|
||||
)
|
||||
replace_exact(
|
||||
ui,
|
||||
@ -45,7 +151,177 @@ replace_exact(
|
||||
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
|
||||
)
|
||||
|
||||
panels = ROOT / "static/panels.js"
|
||||
replace_exact(panels, " tts_voice:'hermes-tts-voice',\n", "")
|
||||
replace_exact(
|
||||
panels,
|
||||
''' const ttsVoiceSel=$('settingsTtsVoice');
|
||||
if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
|
||||
''',
|
||||
"",
|
||||
)
|
||||
replace_exact(
|
||||
panels,
|
||||
''' localStorage.setItem('hermes-tts-engine',this.value);
|
||||
window._populateTtsVoices();
|
||||
_schedulePreferencesAutosave();''',
|
||||
''' localStorage.setItem('hermes-tts-engine',this.value);
|
||||
_schedulePreferencesAutosave();''',
|
||||
)
|
||||
replace_between_exact(
|
||||
panels,
|
||||
" // Populate voice selector based on engine\n",
|
||||
" // TTS rate/pitch sliders\n",
|
||||
" // TTS speaker selection is intentionally server policy only.\n",
|
||||
)
|
||||
replace_exact(
|
||||
panels,
|
||||
"let _settingsSpeechChangedKeys=new Set();\n",
|
||||
"let _settingsSpeechChangedKeys=new Set();\n"
|
||||
"try{localStorage.removeItem('hermes-tts-voice');}catch(_){}\n",
|
||||
)
|
||||
|
||||
boot = ROOT / "static/boot.js"
|
||||
replace_exact(
|
||||
boot,
|
||||
''' voice: localStorage.getItem("hermes-tts-voice")||'',
|
||||
''',
|
||||
"",
|
||||
)
|
||||
replace_exact(
|
||||
boot,
|
||||
''' const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
|
||||
''',
|
||||
"",
|
||||
)
|
||||
replace_exact(
|
||||
boot,
|
||||
" body: JSON.stringify({text: clean, voice, rate, pitch})",
|
||||
" body: JSON.stringify({text: clean, rate, pitch})",
|
||||
)
|
||||
replace_exact(
|
||||
boot,
|
||||
''' const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||||
const voices=speechSynthesis.getVoices();
|
||||
if(savedVoice&&voices.length){
|
||||
const match=voices.find(v=>v.name===savedVoice);
|
||||
if(match) utter.voice=match;
|
||||
}
|
||||
''',
|
||||
"",
|
||||
)
|
||||
replace_exact(boot, " tts_voice:'',\n", "")
|
||||
replace_exact(boot, " ['tts_voice','hermes-tts-voice'],\n", "")
|
||||
|
||||
config = ROOT / "api/config.py"
|
||||
replace_exact(config, ' "tts_voice": "",\n', "")
|
||||
replace_exact(config, ' "tts_voice",\n', "")
|
||||
replace_exact(
|
||||
config,
|
||||
''' if k == "tts_voice":
|
||||
if not isinstance(v, str) or len(v) > 200 or "\\x00" in v:
|
||||
continue
|
||||
''',
|
||||
"",
|
||||
)
|
||||
|
||||
assert_absent(index, "settingsTtsVoice", "settings_label_tts_voice")
|
||||
assert_absent(ui, "hermes-tts-voice", "voice:voice")
|
||||
assert_absent(panels, "settingsTtsVoice", "tts_voice")
|
||||
assert_absent(boot, "hermes-tts-voice", "tts_voice", "text: clean, voice")
|
||||
assert_absent(config, '"tts_voice"')
|
||||
|
||||
i18n = ROOT / "static/i18n.js"
|
||||
remove_lines_containing(
|
||||
i18n,
|
||||
"settings_label_tts_voice:",
|
||||
"settings_desc_tts_voice:",
|
||||
)
|
||||
assert_absent(i18n, "settings_label_tts_voice", "settings_desc_tts_voice")
|
||||
|
||||
# The private Whisper service reports the language it decoded with. Carry that
|
||||
# through the agent's local-command STT envelope so the WebUI can hand a voice
|
||||
# hint to Piper instead of guessing the reply's language from its text.
|
||||
transcription = AGENT_ROOT / "tools/transcription_tools.py"
|
||||
replace_exact(
|
||||
transcription,
|
||||
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
||||
logger.info(
|
||||
"Transcribed %s via local STT command (%s, %d chars)",
|
||||
Path(file_path).name,
|
||||
normalized_model,
|
||||
len(transcript_text),
|
||||
)
|
||||
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
|
||||
''',
|
||||
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
||||
logger.info(
|
||||
"Transcribed %s via local STT command (%s, %d chars)",
|
||||
Path(file_path).name,
|
||||
normalized_model,
|
||||
len(transcript_text),
|
||||
)
|
||||
detected_language = ""
|
||||
language_files = sorted(Path(output_dir).glob("*.language"))
|
||||
if language_files:
|
||||
try:
|
||||
candidate = language_files[0].read_text(encoding="utf-8").strip().lower()
|
||||
except (OSError, ValueError):
|
||||
candidate = ""
|
||||
if 2 <= len(candidate) <= 3 and candidate.isascii() and candidate.isalpha():
|
||||
detected_language = candidate
|
||||
return {
|
||||
"success": True,
|
||||
"transcript": transcript_text,
|
||||
"provider": "local_command",
|
||||
"language": detected_language,
|
||||
}
|
||||
''',
|
||||
)
|
||||
|
||||
upload = ROOT / "api/upload.py"
|
||||
replace_exact(
|
||||
upload,
|
||||
""" transcript = str(result.get('transcript') or '').strip()
|
||||
return j(handler, {'ok': True, 'transcript': transcript})
|
||||
""",
|
||||
""" transcript = str(result.get('transcript') or '').strip()
|
||||
detected = str(result.get('language') or '').strip().lower()
|
||||
if not (2 <= len(detected) <= 3 and detected.isascii() and detected.isalpha()):
|
||||
detected = ''
|
||||
return j(handler, {'ok': True, 'transcript': transcript, 'language': detected})
|
||||
""",
|
||||
)
|
||||
|
||||
routes = ROOT / "api/routes.py"
|
||||
replace_exact(
|
||||
routes,
|
||||
"def _tts_open(req, *, timeout=30, opener_factory=None):",
|
||||
'''ATLAS_TTS_LANGUAGES = ("en", "ru", "es")
|
||||
|
||||
|
||||
def _atlas_tts_language(body):
|
||||
"""Return a plain, allow-listed en/ru/es code, or "" to send no language.
|
||||
|
||||
This is a trust boundary, not a parser. Only the exact normalized codes the
|
||||
private Piper deployment bakes a voice for are forwarded; a missing field,
|
||||
a wrong type, a region tag, padding, control characters, a traversal or
|
||||
injection string, an oversized value, an object, an array, a number or a
|
||||
client-supplied "voice" all resolve to "" and the language field is then
|
||||
omitted entirely, so the Jetson service applies its own English default.
|
||||
Coercing a malformed value into a supported code would let a browser
|
||||
describe hostile input as a language we support; omission cannot.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
value = body.get("language")
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value if value in ATLAS_TTS_LANGUAGES else ""
|
||||
|
||||
|
||||
def _tts_open(req, *, timeout=30, opener_factory=None):''',
|
||||
)
|
||||
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
|
||||
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
|
||||
if engine == "atlas":
|
||||
@ -60,12 +336,19 @@ atlas = ''' # ── Atlas private Jetson TTS ──────────
|
||||
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
|
||||
except ValueError:
|
||||
speed = 1.0
|
||||
request_body = json.dumps({
|
||||
request_payload = {
|
||||
"model": "piper",
|
||||
"input": text,
|
||||
"voice": "en_US-lessac-high",
|
||||
"speed": speed,
|
||||
}).encode("utf-8")
|
||||
}
|
||||
# Attach a language ONLY when the browser sent a plain allow-listed
|
||||
# code. Omitting it is the fail-safe: the Jetson service then speaks
|
||||
# its own English default, which is also what every partially rolled
|
||||
# out combination of these components degrades to.
|
||||
_atlas_language = _atlas_tts_language(data)
|
||||
if _atlas_language:
|
||||
request_payload["language"] = _atlas_language
|
||||
request_body = json.dumps(request_payload).encode("utf-8")
|
||||
request = Request(atlas_url, data=request_body, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "audio/wav",
|
||||
|
||||
336
dockerfiles/hermes-webui-atlas-voice.css
Normal file
336
dockerfiles/hermes-webui-atlas-voice.css
Normal file
@ -0,0 +1,336 @@
|
||||
/* Private hands-free conversation instrument for the Atlas voice bridge. */
|
||||
.voice-mode-bar {
|
||||
--voice-accent: 72, 207, 204;
|
||||
--voice-accent-secondary: 76, 164, 205;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
box-sizing: border-box;
|
||||
min-height: 106px;
|
||||
padding: 9px 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid rgba(174, 192, 218, 0.1);
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.075), transparent 46%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(4, 8, 18, 0.035));
|
||||
}
|
||||
|
||||
.voice-mode-bar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 18%;
|
||||
right: 18%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(233, 244, 255, 0.16), transparent);
|
||||
}
|
||||
|
||||
:root:not(.dark) .voice-mode-bar {
|
||||
border-bottom-color: rgba(38, 55, 78, 0.1);
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.09), transparent 48%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(226, 232, 240, 0.12));
|
||||
}
|
||||
|
||||
.voice-mode-indicator {
|
||||
--voice-ripple-scale: 1.035;
|
||||
--voice-ripple-opacity: 0.3;
|
||||
position: relative;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
flex: 0 0 70px;
|
||||
border-radius: 50%;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.voice-mode-indicator.listening {
|
||||
--voice-accent: 69, 218, 207;
|
||||
--voice-accent-secondary: 73, 169, 209;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.transcribing {
|
||||
--voice-accent: 73, 193, 218;
|
||||
--voice-accent-secondary: 93, 129, 224;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.thinking {
|
||||
--voice-accent: 135, 111, 244;
|
||||
--voice-accent-secondary: 81, 62, 190;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.speaking {
|
||||
--voice-accent: 249, 169, 105;
|
||||
--voice-accent-secondary: 236, 112, 137;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.error {
|
||||
--voice-accent: 235, 135, 88;
|
||||
--voice-accent-secondary: 198, 74, 78;
|
||||
}
|
||||
|
||||
.voice-instrument-halo,
|
||||
.voice-instrument-ripple,
|
||||
.voice-instrument-orbit,
|
||||
.voice-instrument-core {
|
||||
position: absolute;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.voice-instrument-halo {
|
||||
inset: 2px;
|
||||
opacity: 0;
|
||||
background: radial-gradient(circle, rgba(var(--voice-accent), 0.2), rgba(var(--voice-accent), 0.05) 43%, transparent 70%);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.voice-instrument-ripple {
|
||||
inset: 3px;
|
||||
opacity: 0;
|
||||
border: 1px solid rgba(var(--voice-accent), 0.56);
|
||||
box-shadow: 0 0 12px rgba(var(--voice-accent), 0.13);
|
||||
}
|
||||
|
||||
.voice-instrument-orbit {
|
||||
inset: 2px;
|
||||
opacity: 0;
|
||||
border: 1px solid rgba(var(--voice-accent), 0.18);
|
||||
box-shadow:
|
||||
inset 0 0 8px rgba(var(--voice-accent), 0.06),
|
||||
0 0 11px rgba(var(--voice-accent), 0.07);
|
||||
}
|
||||
|
||||
.voice-instrument-orbit::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
border-top: 1.5px solid rgba(var(--voice-accent), 0.88);
|
||||
border-right: 1px solid rgba(var(--voice-accent-secondary), 0.35);
|
||||
border-bottom: 1px solid transparent;
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
.voice-instrument-orbit::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin-left: -2.5px;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--voice-accent));
|
||||
box-shadow:
|
||||
0 0 5px rgba(var(--voice-accent), 0.92),
|
||||
0 0 12px rgba(var(--voice-accent), 0.44);
|
||||
}
|
||||
|
||||
.voice-instrument-core {
|
||||
inset: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(242, 250, 255, 0.94);
|
||||
border: 1px solid rgba(238, 247, 255, 0.2);
|
||||
background:
|
||||
radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.26), transparent 24%),
|
||||
radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.36), transparent 64%),
|
||||
linear-gradient(145deg, rgba(36, 45, 61, 0.96), rgba(10, 15, 27, 0.98));
|
||||
box-shadow:
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.22),
|
||||
inset 0 -9px 18px rgba(0, 0, 0, 0.24),
|
||||
0 4px 13px rgba(0, 0, 0, 0.25),
|
||||
0 0 17px rgba(var(--voice-accent), 0.17);
|
||||
}
|
||||
|
||||
:root:not(.dark) .voice-instrument-core {
|
||||
color: rgba(31, 48, 66, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.82);
|
||||
background:
|
||||
radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.96), transparent 27%),
|
||||
radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.2), transparent 65%),
|
||||
linear-gradient(145deg, rgba(248, 250, 251, 0.98), rgba(216, 225, 231, 0.96));
|
||||
box-shadow:
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.94),
|
||||
inset 0 -8px 17px rgba(64, 84, 100, 0.1),
|
||||
0 4px 12px rgba(35, 53, 72, 0.15),
|
||||
0 0 15px rgba(var(--voice-accent), 0.13);
|
||||
}
|
||||
|
||||
.voice-instrument-symbol {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32));
|
||||
}
|
||||
|
||||
.voice-instrument-symbol svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.voice-symbol {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.listening .voice-symbol-listening,
|
||||
.voice-mode-indicator.transcribing .voice-symbol-transcribing,
|
||||
.voice-mode-indicator.thinking .voice-symbol-thinking,
|
||||
.voice-mode-indicator.speaking .voice-symbol-speaking,
|
||||
.voice-mode-indicator.error .voice-symbol-error {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.listening .voice-instrument-halo {
|
||||
opacity: 0.62;
|
||||
animation: voice-instrument-breathe 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.listening .voice-instrument-ripple {
|
||||
opacity: var(--voice-ripple-opacity);
|
||||
transform: scale(var(--voice-ripple-scale));
|
||||
transition: transform 140ms ease-out, opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.transcribing .voice-instrument-orbit {
|
||||
opacity: 0.92;
|
||||
animation: voice-instrument-orbit 3.4s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.thinking .voice-instrument-orbit {
|
||||
opacity: 0.96;
|
||||
animation: voice-instrument-orbit 4.6s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.thinking .voice-instrument-core {
|
||||
animation: voice-instrument-core-breathe 5.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.speaking .voice-instrument-orbit {
|
||||
opacity: 0.86;
|
||||
transform: rotate(38deg);
|
||||
}
|
||||
|
||||
.voice-mode-indicator.speaking.is-playing .voice-instrument-halo {
|
||||
opacity: 0.66;
|
||||
animation: voice-instrument-speaking-pulse 2.15s ease-out infinite;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.error .voice-instrument-orbit {
|
||||
opacity: 0.9;
|
||||
transform: rotate(-32deg);
|
||||
border-color: rgba(var(--voice-accent), 0.44);
|
||||
}
|
||||
|
||||
.voice-mode-indicator.error .voice-instrument-core {
|
||||
color: rgba(255, 232, 219, 0.95);
|
||||
box-shadow:
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.2),
|
||||
inset 0 -9px 18px rgba(0, 0, 0, 0.24),
|
||||
0 4px 13px rgba(0, 0, 0, 0.25),
|
||||
0 0 12px rgba(var(--voice-accent), 0.17);
|
||||
}
|
||||
|
||||
.voice-mode-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 16px;
|
||||
max-width: min(100%, 34rem);
|
||||
overflow: hidden;
|
||||
color: var(--text, #f4f6fb);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.018em;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
:root:not(.dark) .voice-mode-label {
|
||||
color: #263648;
|
||||
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
@keyframes voice-instrument-breathe {
|
||||
0%, 100% { opacity: 0.38; transform: scale(0.94); }
|
||||
50% { opacity: 0.72; transform: scale(1.06); }
|
||||
}
|
||||
|
||||
@keyframes voice-instrument-orbit {
|
||||
0% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(177deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes voice-instrument-core-breathe {
|
||||
0%, 100% { filter: saturate(0.96) brightness(0.98); }
|
||||
50% { filter: saturate(1.08) brightness(1.06); }
|
||||
}
|
||||
|
||||
@keyframes voice-instrument-speaking-pulse {
|
||||
0% { opacity: 0.58; transform: scale(0.88); }
|
||||
58% { opacity: 0.22; transform: scale(1.12); }
|
||||
100% { opacity: 0; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.voice-mode-bar {
|
||||
min-height: 94px;
|
||||
padding: 7px 10px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.voice-mode-indicator {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
flex-basis: 62px;
|
||||
}
|
||||
|
||||
.voice-instrument-core {
|
||||
inset: 11px;
|
||||
}
|
||||
|
||||
.voice-instrument-symbol {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.voice-mode-bar *,
|
||||
.voice-mode-bar *::before,
|
||||
.voice-mode-bar *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.listening .voice-instrument-halo {
|
||||
opacity: 0.45;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.voice-mode-indicator.listening .voice-instrument-ripple {
|
||||
opacity: 0.34;
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
.voice-mode-indicator.transcribing .voice-instrument-orbit,
|
||||
.voice-mode-indicator.thinking .voice-instrument-orbit {
|
||||
transform: rotate(24deg);
|
||||
}
|
||||
|
||||
.voice-mode-indicator.speaking.is-playing .voice-instrument-halo {
|
||||
opacity: 0.42;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// Natural turn-taking for chat.hermes.bstein.dev using the private Jetsons.
|
||||
// Natural turn-taking for chat.bstein.dev using the private Jetsons.
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
@ -19,9 +19,49 @@
|
||||
let vadTimer=null;
|
||||
let currentAudio=null;
|
||||
let thinkingSession=null;
|
||||
let errorTimer=null;
|
||||
let visualInputLevel=0;
|
||||
const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false};
|
||||
const ERROR_VISIBLE_MS=3200;
|
||||
const STATE_LABELS={
|
||||
listening:'Listening',
|
||||
transcribing:'Transcribing…',
|
||||
thinking:'Thinking…',
|
||||
speaking:'Speaking',
|
||||
error:'Voice unavailable',
|
||||
idle:'',
|
||||
};
|
||||
// The only language signal this file trusts is the one the private Whisper
|
||||
// service returned for the audio of the turn currently being answered. It is
|
||||
// bound to that turn's generation token and consumed exactly once.
|
||||
let sttLanguage='';
|
||||
let sttLanguageToken=-1;
|
||||
const TTS_LANGUAGES=['en','ru','es'];
|
||||
const originalAutoRead=window.autoReadLastAssistant;
|
||||
const originalApplyPreference=window._applyVoiceModePref;
|
||||
|
||||
function normalizeSttLanguage(value){
|
||||
if(typeof value!=='string') return '';
|
||||
const code=value.trim().toLowerCase();
|
||||
return TTS_LANGUAGES.indexOf(code)>=0?code:'';
|
||||
}
|
||||
|
||||
function clearSttLanguage(){
|
||||
sttLanguage='';
|
||||
sttLanguageToken=-1;
|
||||
}
|
||||
|
||||
function rememberSttLanguage(language, token){
|
||||
sttLanguage=language||'';
|
||||
sttLanguageToken=sttLanguage?token:-1;
|
||||
}
|
||||
|
||||
function takeSttLanguage(token){
|
||||
const language=sttLanguageToken===token?sttLanguage:'';
|
||||
clearSttLanguage();
|
||||
return language;
|
||||
}
|
||||
|
||||
function toast(message){
|
||||
if(typeof window.showToast==='function') window.showToast(message,3000);
|
||||
}
|
||||
@ -29,8 +69,48 @@
|
||||
function setState(next, customLabel){
|
||||
state=next;
|
||||
indicator.className='voice-mode-indicator '+next;
|
||||
label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':'');
|
||||
bar.style.display=active&&next!=='idle'?'':'none';
|
||||
bar.dataset.voiceState=next;
|
||||
bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false');
|
||||
label.textContent=customLabel||STATE_LABELS[next]||'';
|
||||
bar.style.display=(active&&next!=='idle')||next==='error'?'':'none';
|
||||
resetInputLevel();
|
||||
}
|
||||
|
||||
function resetInputLevel(){
|
||||
visualInputLevel=0;
|
||||
indicator.style.setProperty('--voice-ripple-scale','1.035');
|
||||
indicator.style.setProperty('--voice-ripple-opacity','0.3');
|
||||
}
|
||||
|
||||
function updateInputLevel(rms){
|
||||
if(reducedMotion.matches||state!=='listening') return;
|
||||
const target=Math.max(0,Math.min(1,(rms-0.01)/0.18));
|
||||
visualInputLevel=(visualInputLevel*0.72)+(target*0.28);
|
||||
indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3));
|
||||
indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3));
|
||||
}
|
||||
|
||||
function clearErrorTimer(){
|
||||
if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;}
|
||||
}
|
||||
|
||||
function errorMessage(error, fallback){
|
||||
return String((error&&error.message)||fallback||'Voice unavailable').trim();
|
||||
}
|
||||
|
||||
function showUnavailable(message){
|
||||
generation+=1;
|
||||
active=false;
|
||||
thinkingSession=null;
|
||||
stopCapture();
|
||||
stopPlayback();
|
||||
modeBtn.classList.remove('active');
|
||||
setState('error',message);
|
||||
clearErrorTimer();
|
||||
errorTimer=window.setTimeout(function(){
|
||||
errorTimer=null;
|
||||
if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle');
|
||||
},ERROR_VISIBLE_MS);
|
||||
}
|
||||
|
||||
function stopCapture(){
|
||||
@ -47,17 +127,19 @@
|
||||
if(!currentAudio) return;
|
||||
try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ }
|
||||
currentAudio=null;
|
||||
indicator.classList.remove('is-playing');
|
||||
}
|
||||
|
||||
function deactivate(showMessage){
|
||||
generation+=1;
|
||||
active=false;
|
||||
state='idle';
|
||||
thinkingSession=null;
|
||||
clearErrorTimer();
|
||||
clearSttLanguage();
|
||||
stopCapture();
|
||||
stopPlayback();
|
||||
modeBtn.classList.remove('active');
|
||||
bar.style.display='none';
|
||||
setState('idle');
|
||||
if(showMessage) toast('Hands-free voice mode off');
|
||||
}
|
||||
|
||||
@ -67,32 +149,41 @@
|
||||
},delay||500);
|
||||
}
|
||||
|
||||
function sendTranscript(transcript, token){
|
||||
function sendTranscript(transcript, token, language){
|
||||
if(!active||token!==generation) return;
|
||||
const text=String(transcript||'').trim();
|
||||
if(!text){restartSoon(token,350);return;}
|
||||
if(!text){clearSttLanguage();restartSoon(token,350);return;}
|
||||
composer.value=text;
|
||||
if(typeof window.autoResize==='function') window.autoResize();
|
||||
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||
rememberSttLanguage(language,token);
|
||||
setState('thinking');
|
||||
if(typeof window.send==='function') window.send();
|
||||
}
|
||||
|
||||
function audioExtension(mimeType){
|
||||
const normalized=String(mimeType||'').toLowerCase();
|
||||
if(normalized.indexOf('ogg')>=0) return 'ogg';
|
||||
if(normalized.indexOf('mp4')>=0) return 'mp4';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
async function transcribe(blob, token){
|
||||
if(!active||token!==generation) return;
|
||||
setState('thinking','Transcribing…');
|
||||
const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm';
|
||||
setState('transcribing');
|
||||
const ext=audioExtension(blob.type);
|
||||
const form=new FormData();
|
||||
form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext}));
|
||||
try{
|
||||
const response=await fetch('/api/transcribe',{method:'POST',body:form});
|
||||
const payload=await response.json().catch(function(){return {};});
|
||||
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
|
||||
sendTranscript(payload.transcript,token);
|
||||
sendTranscript(payload.transcript,token,normalizeSttLanguage(payload.language));
|
||||
}catch(error){
|
||||
if(!active||token!==generation) return;
|
||||
deactivate(false);
|
||||
toast((error&&error.message)||'Private Whisper is unavailable');
|
||||
const message=errorMessage(error,'Private Whisper is unavailable');
|
||||
showUnavailable(message);
|
||||
toast(message);
|
||||
// If the browser supplies its own recognizer, hand control back to the
|
||||
// upstream voice implementation until the Jetson becomes healthy again.
|
||||
if(window.SpeechRecognition||window.webkitSpeechRecognition){
|
||||
@ -105,6 +196,7 @@
|
||||
async function startListening(token){
|
||||
if(!active||token!==generation) return;
|
||||
stopCapture();
|
||||
clearSttLanguage();
|
||||
setState('listening');
|
||||
try{
|
||||
const capture=await navigator.mediaDevices.getUserMedia({
|
||||
@ -128,19 +220,26 @@
|
||||
audioContext.createMediaStreamSource(stream).connect(highpass);
|
||||
highpass.connect(analyser);
|
||||
const samples=new Uint8Array(analyser.fftSize);
|
||||
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm'];
|
||||
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/webm'];
|
||||
const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||'';
|
||||
const chunks=[];
|
||||
const preRoll=[];
|
||||
let initialChunk=null;
|
||||
let heardSpeech=false;
|
||||
let voiceFrames=0;
|
||||
let noiseFloor=0.008;
|
||||
let lastSpeech=Date.now();
|
||||
const started=Date.now();
|
||||
recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined);
|
||||
let recordedMime=recorder.mimeType||mime||'';
|
||||
recorder.ondataavailable=function(event){
|
||||
if(!event.data||!event.data.size) return;
|
||||
if(event.data.type) recordedMime=event.data.type;
|
||||
if(heardSpeech){chunks.push(event.data);return;}
|
||||
// MediaRecorder's first timeslice owns the container initialization
|
||||
// (EBML/Opus headers for WebM, and equivalent headers for Ogg/MP4).
|
||||
// Keep it separately while bounding the actual audio pre-roll.
|
||||
if(!initialChunk){initialChunk=event.data;return;}
|
||||
preRoll.push(event.data);
|
||||
while(preRoll.length>3) preRoll.shift();
|
||||
};
|
||||
@ -153,7 +252,7 @@
|
||||
recorder=null;
|
||||
if(!active||token!==generation) return;
|
||||
if(!heardSpeech||!chunks.length){restartSoon(token,300);return;}
|
||||
transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token);
|
||||
transcribe(new Blob(chunks,{type:recordedMime||'audio/webm'}),token);
|
||||
};
|
||||
recorder.start(250);
|
||||
const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600);
|
||||
@ -166,6 +265,7 @@
|
||||
energy+=normalized*normalized;
|
||||
}
|
||||
const rms=Math.sqrt(energy/samples.length);
|
||||
updateInputLevel(rms);
|
||||
const now=Date.now();
|
||||
const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006);
|
||||
const voiceNow=rms>speechThreshold;
|
||||
@ -174,6 +274,7 @@
|
||||
if(!heardSpeech&&voiceFrames>=3){
|
||||
heardSpeech=true;
|
||||
lastSpeech=now;
|
||||
if(initialChunk){chunks.push(initialChunk);initialChunk=null;}
|
||||
while(preRoll.length) chunks.push(preRoll.shift());
|
||||
}else if(heardSpeech&&voiceNow){
|
||||
lastSpeech=now;
|
||||
@ -188,8 +289,9 @@
|
||||
},100);
|
||||
}catch(error){
|
||||
if(!active||token!==generation) return;
|
||||
deactivate(false);
|
||||
toast((error&&error.message)||'Microphone permission is required');
|
||||
const message=errorMessage(error,'Microphone permission is required');
|
||||
showUnavailable(message);
|
||||
toast(message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,19 +308,26 @@
|
||||
currentAudio=audio;
|
||||
function cleanup(){
|
||||
if(currentAudio===audio) currentAudio=null;
|
||||
indicator.classList.remove('is-playing');
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
audio.onended=function(){cleanup();resolve();};
|
||||
audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));};
|
||||
audio.play().catch(function(error){cleanup();reject(error);});
|
||||
audio.play().then(function(){
|
||||
if(active&&token===generation&¤tAudio===audio) indicator.classList.add('is-playing');
|
||||
}).catch(function(error){cleanup();reject(error);});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSpeech(chunk){
|
||||
async function fetchSpeech(chunk, language){
|
||||
// `language` is only ever the private STT result for this turn. When it is
|
||||
// absent the field is omitted entirely and the server picks English.
|
||||
const request={text:chunk,engine:'atlas'};
|
||||
if(language) request.language=language;
|
||||
const response=await fetch('/api/tts',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({text:chunk,engine:'atlas'}),
|
||||
body:JSON.stringify(request),
|
||||
});
|
||||
if(!response.ok){
|
||||
const payload=await response.json().catch(function(){return {};});
|
||||
@ -232,10 +341,12 @@
|
||||
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||
if(thinkingSession&¤tSession&&thinkingSession!==currentSession){
|
||||
thinkingSession=null;
|
||||
clearSttLanguage();
|
||||
restartSoon(token,250);
|
||||
return;
|
||||
}
|
||||
thinkingSession=null;
|
||||
const language=takeSttLanguage(token);
|
||||
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
||||
if(!rows.length){restartSoon(token,250);return;}
|
||||
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
|
||||
@ -243,15 +354,19 @@
|
||||
setState('speaking');
|
||||
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
|
||||
try{
|
||||
let pending=fetchSpeech(chunks[0]);
|
||||
let pending=fetchSpeech(chunks[0],language);
|
||||
for(let index=0;index<chunks.length;index+=1){
|
||||
if(!active||token!==generation) return;
|
||||
const blob=await pending;
|
||||
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1]);
|
||||
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1],language);
|
||||
await playBlob(blob,token);
|
||||
}
|
||||
}catch(error){
|
||||
if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable');
|
||||
if(active&&token===generation){
|
||||
const message=errorMessage(error,'Local speech is unavailable');
|
||||
setState('error',message);
|
||||
toast(message);
|
||||
}
|
||||
}
|
||||
restartSoon(token,450);
|
||||
}
|
||||
@ -260,6 +375,8 @@
|
||||
generation+=1;
|
||||
const token=generation;
|
||||
active=true;
|
||||
clearErrorTimer();
|
||||
clearSttLanguage();
|
||||
modeBtn.classList.add('active');
|
||||
toast('Hands-free private voice mode on');
|
||||
if(typeof window.stopTTS==='function') window.stopTTS();
|
||||
|
||||
37
dockerfiles/hermes-webui-stt-patch.py
Normal file
37
dockerfiles/hermes-webui-stt-patch.py
Normal file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bound local-STT conversion errors in the pinned Hermes Agent source."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
|
||||
|
||||
|
||||
def replace_exact(path: Path, before: str, after: str) -> None:
|
||||
"""Replace one exact upstream fragment, failing closed on image drift."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if source.count(before) != 1:
|
||||
raise SystemExit(f"Hermes STT patch context changed in {path}: {before[:80]!r}")
|
||||
path.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||
|
||||
|
||||
transcription = ROOT / "tools/transcription_tools.py"
|
||||
replace_exact(
|
||||
transcription,
|
||||
""" except subprocess.CalledProcessError as e:
|
||||
details = e.stderr.strip() or e.stdout.strip() or str(e)
|
||||
logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
|
||||
return None, f"Failed to convert audio for local STT: {details}"
|
||||
""",
|
||||
""" except subprocess.CalledProcessError as e:
|
||||
details = e.stderr.strip() or e.stdout.strip() or str(e)
|
||||
logger.error(
|
||||
"ffmpeg conversion failed for %s: %s", file_path, details[-2000:]
|
||||
)
|
||||
return None, (
|
||||
"Audio conversion failed: upload is invalid, incomplete, or uses an "
|
||||
"unsupported codec"
|
||||
)
|
||||
""",
|
||||
)
|
||||
@ -18,6 +18,7 @@ data:
|
||||
192.168.22.9 call.live.bstein.dev
|
||||
192.168.22.9 cd.bstein.dev
|
||||
192.168.22.9 chat.ai.bstein.dev
|
||||
192.168.22.9 chat.bstein.dev
|
||||
192.168.22.9 chat.hermes.bstein.dev
|
||||
192.168.22.9 ci.bstein.dev
|
||||
192.168.22.9 cloud.bstein.dev
|
||||
@ -45,6 +46,7 @@ data:
|
||||
192.168.22.9 stream.bstein.dev
|
||||
192.168.22.9 wolf.bstein.dev
|
||||
192.168.22.9 tasks.bstein.dev
|
||||
192.168.22.9 triage.bstein.dev
|
||||
192.168.22.9 triage.hermes.bstein.dev
|
||||
192.168.22.9 vault.bstein.dev
|
||||
fallthrough
|
||||
|
||||
@ -4404,7 +4404,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"host": "chat.hermes.bstein.dev",
|
||||
"host": "chat.bstein.dev",
|
||||
"path": "/",
|
||||
"backend": {
|
||||
"namespace": "hermes",
|
||||
@ -5164,7 +5164,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"host": "triage.hermes.bstein.dev",
|
||||
"host": "triage.bstein.dev",
|
||||
"path": "/",
|
||||
"backend": {
|
||||
"namespace": "hermes",
|
||||
|
||||
@ -2888,7 +2888,7 @@ http_endpoints:
|
||||
kind: Ingress
|
||||
name: bstein-dev-home
|
||||
source: bstein-dev-home
|
||||
- host: chat.hermes.bstein.dev
|
||||
- host: chat.bstein.dev
|
||||
path: /
|
||||
backend:
|
||||
namespace: hermes
|
||||
@ -3361,7 +3361,7 @@ http_endpoints:
|
||||
kind: Ingress
|
||||
name: planka
|
||||
source: planka
|
||||
- host: triage.hermes.bstein.dev
|
||||
- host: triage.bstein.dev
|
||||
path: /
|
||||
backend:
|
||||
namespace: hermes
|
||||
|
||||
@ -51,9 +51,9 @@ flowchart LR
|
||||
host_chat_ai_bstein_dev --> svc_bstein_dev_home_chat_ai_gateway
|
||||
wl_bstein_dev_home_chat_ai_gateway["bstein-dev-home/chat-ai-gateway (Deployment)"]
|
||||
svc_bstein_dev_home_chat_ai_gateway --> wl_bstein_dev_home_chat_ai_gateway
|
||||
host_chat_hermes_bstein_dev["chat.hermes.bstein.dev"]
|
||||
host_chat_bstein_dev["chat.bstein.dev"]
|
||||
svc_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Service)"]
|
||||
host_chat_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
|
||||
host_chat_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
|
||||
wl_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Deployment)"]
|
||||
svc_hermes_oauth2_proxy_hermes_chat --> wl_hermes_oauth2_proxy_hermes_chat
|
||||
host_ci_bstein_dev["ci.bstein.dev"]
|
||||
@ -170,9 +170,9 @@ flowchart LR
|
||||
host_tasks_bstein_dev --> svc_planka_planka
|
||||
wl_planka_planka["planka/planka (Deployment)"]
|
||||
svc_planka_planka --> wl_planka_planka
|
||||
host_triage_hermes_bstein_dev["triage.hermes.bstein.dev"]
|
||||
host_triage_bstein_dev["triage.bstein.dev"]
|
||||
svc_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Service)"]
|
||||
host_triage_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
|
||||
host_triage_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
|
||||
wl_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Deployment)"]
|
||||
svc_hermes_oauth2_proxy_hermes_triage --> wl_hermes_oauth2_proxy_hermes_triage
|
||||
host_vault_bstein_dev["vault.bstein.dev"]
|
||||
|
||||
@ -20,9 +20,9 @@ resources:
|
||||
- ingress.yaml
|
||||
images:
|
||||
- name: registry.bstein.dev/bstein/bstein-dev-home-frontend
|
||||
newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"}
|
||||
newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"}
|
||||
- name: registry.bstein.dev/bstein/bstein-dev-home-backend
|
||||
newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"}
|
||||
newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"}
|
||||
configMapGenerator:
|
||||
- name: chat-ai-gateway
|
||||
namespace: bstein-dev-home
|
||||
|
||||
@ -4404,7 +4404,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"host": "chat.hermes.bstein.dev",
|
||||
"host": "chat.bstein.dev",
|
||||
"path": "/",
|
||||
"backend": {
|
||||
"namespace": "hermes",
|
||||
@ -5164,7 +5164,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"host": "triage.hermes.bstein.dev",
|
||||
"host": "triage.bstein.dev",
|
||||
"path": "/",
|
||||
"backend": {
|
||||
"namespace": "hermes",
|
||||
|
||||
@ -2888,7 +2888,7 @@ http_endpoints:
|
||||
kind: Ingress
|
||||
name: bstein-dev-home
|
||||
source: bstein-dev-home
|
||||
- host: chat.hermes.bstein.dev
|
||||
- host: chat.bstein.dev
|
||||
path: /
|
||||
backend:
|
||||
namespace: hermes
|
||||
@ -3361,7 +3361,7 @@ http_endpoints:
|
||||
kind: Ingress
|
||||
name: planka
|
||||
source: planka
|
||||
- host: triage.hermes.bstein.dev
|
||||
- host: triage.bstein.dev
|
||||
path: /
|
||||
backend:
|
||||
namespace: hermes
|
||||
|
||||
@ -51,9 +51,9 @@ flowchart LR
|
||||
host_chat_ai_bstein_dev --> svc_bstein_dev_home_chat_ai_gateway
|
||||
wl_bstein_dev_home_chat_ai_gateway["bstein-dev-home/chat-ai-gateway (Deployment)"]
|
||||
svc_bstein_dev_home_chat_ai_gateway --> wl_bstein_dev_home_chat_ai_gateway
|
||||
host_chat_hermes_bstein_dev["chat.hermes.bstein.dev"]
|
||||
host_chat_bstein_dev["chat.bstein.dev"]
|
||||
svc_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Service)"]
|
||||
host_chat_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
|
||||
host_chat_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
|
||||
wl_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Deployment)"]
|
||||
svc_hermes_oauth2_proxy_hermes_chat --> wl_hermes_oauth2_proxy_hermes_chat
|
||||
host_ci_bstein_dev["ci.bstein.dev"]
|
||||
@ -170,9 +170,9 @@ flowchart LR
|
||||
host_tasks_bstein_dev --> svc_planka_planka
|
||||
wl_planka_planka["planka/planka (Deployment)"]
|
||||
svc_planka_planka --> wl_planka_planka
|
||||
host_triage_hermes_bstein_dev["triage.hermes.bstein.dev"]
|
||||
host_triage_bstein_dev["triage.bstein.dev"]
|
||||
svc_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Service)"]
|
||||
host_triage_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
|
||||
host_triage_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
|
||||
wl_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Deployment)"]
|
||||
svc_hermes_oauth2_proxy_hermes_triage --> wl_hermes_oauth2_proxy_hermes_triage
|
||||
host_vault_bstein_dev["vault.bstein.dev"]
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Hermes on Atlas: operator guide
|
||||
|
||||
This is the mental model and demonstration script for the operator instance at
|
||||
`triage.hermes.bstein.dev`. Read it once, then prove each section in the live UI. The
|
||||
consumer instance at `chat.hermes.bstein.dev` is intentionally separate and is not the
|
||||
`triage.bstein.dev`. Read it once, then prove each section in the live UI. The
|
||||
consumer instance at `chat.bstein.dev` is intentionally separate and is not the
|
||||
place to perform infrastructure triage.
|
||||
|
||||
`agent.hermes.bstein.dev` is the owner-only engineering control plane. Its root
|
||||
@ -14,7 +14,7 @@ its conversation-first layout is a better fit.
|
||||
|
||||
## Consumer chat and Telegram
|
||||
|
||||
`chat.hermes.bstein.dev` uses the pinned Hermes WebUI rather than the operator
|
||||
`chat.bstein.dev` uses the pinned Hermes WebUI rather than the operator
|
||||
dashboard. Keycloak still authenticates every browser request, and the tenant
|
||||
router permanently assigns each Keycloak subject to one Hermes process and one
|
||||
PVC. The four slots are an isolation pool, not a provider round robin: every
|
||||
@ -40,6 +40,40 @@ or the WebUI. Browser chat remains available when `bot_token` is empty.
|
||||
The bot token and relay key must never be added to Git or a Kubernetes Secret.
|
||||
The router does not log prompt bodies, raw Telegram IDs, link codes, or tokens.
|
||||
|
||||
## Private Jetson voice: multilingual TTS policy
|
||||
|
||||
`hermes-tts` on `titan-21` bakes three checksum-pinned Piper voices and
|
||||
selects one per request from a fixed, allow-listed `language` field: `en`/
|
||||
`en-US` → `en_US-amy-medium`, `ru`/`ru-RU` → `ru_RU-irina-medium`, `es`/
|
||||
`es-MX`/`es-ES` → `es_MX-claude-high` (Piper's `claude` voice is Mexican
|
||||
Spanish; there is no Castilian `es_ES-claude`). Matching is case-insensitive
|
||||
and accepts both `_` and `-` separators. Any language that is missing,
|
||||
unrecognized, or malformed falls back to English amy rather than erroring.
|
||||
The mapping is a fixed dict from `language` to one of the three baked model
|
||||
names only — a client-supplied `voice` field is never read, so no client
|
||||
input can select or construct a model path. All three voices are preloaded
|
||||
at process start (see `dockerfiles/hermes-jetson-tts-server.py`).
|
||||
|
||||
Hermes Chat deliberately exposes no TTS speaker/model choice. The deterministic
|
||||
WebUI image patch removes the pinned upstream voice selector, its label and
|
||||
translations, its browser/server preference persistence, and every outbound
|
||||
client `voice` field while preserving the TTS engine, speech rate/pitch,
|
||||
dictation, hands-free Voice Mode, and the conversation instrument. Legacy
|
||||
`hermes-tts-voice` browser state is deleted. Voice choice is therefore policy,
|
||||
not a client preference: validated English maps to amy, Russian to irina,
|
||||
Spanish to claude, and every unsupported or absent language falls back to amy.
|
||||
|
||||
The private WebUI voice bridge (`dockerfiles/hermes-webui-atlas-voice.js`,
|
||||
patched into `api/routes.py` by `hermes-webui-atlas-patch.py`) has no signal
|
||||
for the language of the assistant reply it is about to speak — it sends only
|
||||
`text` and `engine`. Until the WebUI or gateway attaches an explicit
|
||||
`language` field to that request, every reply speaks in the safe English
|
||||
default regardless of its actual language. Closing that gap needs a language
|
||||
signal upstream of the TTS call (e.g. tagging the assistant turn with a
|
||||
detected/declared reply language and threading it through
|
||||
`hermes-webui-atlas-voice.js` → `api/routes.py` → the `language` field), not
|
||||
client- or server-side guessing bolted onto the TTS service itself.
|
||||
|
||||
## The one-sentence explanation
|
||||
|
||||
Hermes is the persistent agent runtime and control surface; Codex or the local
|
||||
@ -347,6 +381,40 @@ Use this short explanation:
|
||||
- `Use $tune-atlas-alerts. Trace one currently firing alert to its generated source and raw PromQL, but do not edit it.`
|
||||
- `Use $master-hermes-on-atlas. Assess me on the request path and permission boundary. One question at a time.`
|
||||
|
||||
## Private voice: choosing the Piper voice from the STT-detected language
|
||||
|
||||
Hands-free voice mode in `chat.hermes.bstein.dev` selects the private Piper
|
||||
voice from the language the private Jetson Whisper service reports for the
|
||||
user's own speech. The signal travels one way only, and every hop narrows it:
|
||||
|
||||
1. `hermes-stt` returns `{text, model, language}`. `language` is whatever
|
||||
Whisper decoded with, accepted only as a bare ISO-639 token (`en`, `ru`,
|
||||
`es`, `yue`, …); anything else is reported as empty.
|
||||
2. `hermes_stt_client.py` writes the usual `<stem>.txt` transcript plus a
|
||||
`<stem>.language` sidecar. The transcript stays the only `.txt` in the
|
||||
output directory, so the stock Hermes local-command contract is unchanged.
|
||||
3. The patched local-command STT envelope reads that sidecar and adds
|
||||
`language` to its result; `/api/transcribe` re-validates it and returns it
|
||||
next to `transcript`.
|
||||
4. `atlas-voice.js` keeps that value only for the turn it belongs to. It is
|
||||
bound to the voice-mode generation token and the chat session id, consumed
|
||||
exactly once by the reply that turn produced, and cleared on cancellation,
|
||||
restart, session change, an empty transcript, or a transcription error.
|
||||
5. `/api/tts` accepts `language` only from the fixed allow-list and otherwise
|
||||
sends English. The Jetson TTS service applies the same allow-list again as
|
||||
the final authority.
|
||||
|
||||
**What this does not claim.** The language is the language the *user spoke*,
|
||||
not the language of the reply. A model asked a Russian question may answer in
|
||||
English and will then be read aloud by the Russian voice, and vice versa; this
|
||||
is a deliberate policy choice for hands-free mode, not a detection failure.
|
||||
Nothing here detects the language of assistant text.
|
||||
|
||||
**Everything else stays English.** Typed messages, the manual read-aloud
|
||||
button, and any assistant reply that was not produced by a hands-free spoken
|
||||
turn carry no trusted STT signal, so they synthesize with `en_US-amy-medium`.
|
||||
A `voice` field from a browser is never honoured at any hop.
|
||||
|
||||
## Honest limits
|
||||
|
||||
- Hermes does not currently apply production or cluster changes autonomously.
|
||||
|
||||
@ -11,5 +11,9 @@ spec:
|
||||
name: letsencrypt
|
||||
dnsNames:
|
||||
- agent.hermes.bstein.dev
|
||||
- chat.bstein.dev
|
||||
- triage.bstein.dev
|
||||
# Legacy hosts stay on the certificate until they are retired on purpose;
|
||||
# the rename in #34 must not break links or sessions already in flight.
|
||||
- chat.hermes.bstein.dev
|
||||
- triage.hermes.bstein.dev
|
||||
|
||||
@ -84,7 +84,9 @@ data:
|
||||
failure_limit: 2
|
||||
orchestrator_profile: default
|
||||
default_assignee: cli-auto
|
||||
max_in_progress_per_profile: 1
|
||||
# Allow a release review and independent evidence task to run together;
|
||||
# gateway-wide and provider quota/cooldown limits remain in force.
|
||||
max_in_progress_per_profile: 2
|
||||
auto_decompose: true
|
||||
auto_decompose_per_tick: 2
|
||||
# In-pod autonomous supervisor (kanban_supervisor.py). Default false so it
|
||||
@ -320,7 +322,7 @@ data:
|
||||
cannot read Secrets, exec or attach to pods, create service-account tokens,
|
||||
mutate workloads or RBAC, or reconcile Flux. Put every durable cluster
|
||||
change on a reviewed titan-iac branch. Never expose credentials in chat or
|
||||
logs. Triage belongs at triage.hermes.bstein.dev.
|
||||
logs. Triage belongs at triage.bstein.dev.
|
||||
|
||||
## Atlas engineering access
|
||||
|
||||
|
||||
@ -88,10 +88,35 @@ spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- agent.hermes.bstein.dev
|
||||
- chat.bstein.dev
|
||||
- triage.bstein.dev
|
||||
- chat.hermes.bstein.dev
|
||||
- triage.hermes.bstein.dev
|
||||
secretName: hermes-sites-tls
|
||||
rules:
|
||||
- host: chat.bstein.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: oauth2-proxy-hermes-chat
|
||||
port:
|
||||
name: http
|
||||
- host: triage.bstein.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: oauth2-proxy-hermes-triage
|
||||
port:
|
||||
name: http
|
||||
# Legacy hosts serve the same backends so the rename is additive. They are
|
||||
# kept until an explicit retirement change, not redirected: oauth2-proxy
|
||||
# cookies are host-bound, so a redirect would silently drop the session.
|
||||
- host: chat.hermes.bstein.dev
|
||||
http:
|
||||
paths:
|
||||
|
||||
@ -68,7 +68,7 @@ data:
|
||||
atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus}
|
||||
atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b}
|
||||
dashboard:
|
||||
public_url: https://chat.hermes.bstein.dev
|
||||
public_url: https://chat.bstein.dev
|
||||
display:
|
||||
compact: true
|
||||
tool_progress: all
|
||||
|
||||
@ -277,7 +277,7 @@ spec:
|
||||
- {name: API_SERVER_ENABLED, value: "true"}
|
||||
- {name: API_SERVER_HOST, value: 0.0.0.0}
|
||||
- {name: API_SERVER_PORT, value: "8642"}
|
||||
- {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
|
||||
- {name: API_SERVER_CORS_ORIGINS, value: https://chat.bstein.dev}
|
||||
- {name: HERMES_IMAGE_BROKER_URL, value: http://hermes-image-broker.hermes.svc.cluster.local:9002}
|
||||
- {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
|
||||
- {name: HERMES_AUTO_ROUTER_PROFILE, value: chat}
|
||||
@ -345,7 +345,7 @@ spec:
|
||||
# NetworkPolicy admits this port only from hermes-chat-router; the
|
||||
# CIDR lets the WebUI validate that router's changing pod address.
|
||||
- {name: HERMES_WEBUI_TRUSTED_PROXY_CIDRS, value: 10.42.0.0/16}
|
||||
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
|
||||
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.bstein.dev}
|
||||
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
|
||||
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
|
||||
- {name: HERMES_ROUTER_PROFILE, value: chat}
|
||||
|
||||
@ -83,7 +83,7 @@ data:
|
||||
- "*kubectl describe secret*"
|
||||
|
||||
dashboard:
|
||||
public_url: https://triage.hermes.bstein.dev
|
||||
public_url: https://triage.bstein.dev
|
||||
|
||||
display:
|
||||
compact: true
|
||||
@ -111,10 +111,10 @@ data:
|
||||
You are Hermes running inside the Titan Kubernetes cluster as a supervised
|
||||
testing and operations triage assistant.
|
||||
|
||||
This is the dedicated triage appliance at triage.hermes.bstein.dev. Keep
|
||||
This is the dedicated triage appliance at triage.bstein.dev. Keep
|
||||
automated Ariadne intake and testing conversations here. Project delivery
|
||||
and coding orchestration belong to agent.hermes.bstein.dev; general user
|
||||
chat belongs to chat.hermes.bstein.dev.
|
||||
chat belongs to chat.bstein.dev.
|
||||
|
||||
Start in AUTO routing with a careful, intelligence-biased posture. Every
|
||||
new request is classified locally before a hosted model is selected. The
|
||||
|
||||
@ -284,7 +284,7 @@ spec:
|
||||
- name: HERMES_DASHBOARD
|
||||
value: "0"
|
||||
- name: HERMES_DASHBOARD_PUBLIC_URL
|
||||
value: https://triage.hermes.bstein.dev
|
||||
value: https://triage.bstein.dev
|
||||
- name: API_SERVER_ENABLED
|
||||
value: "true"
|
||||
- name: API_SERVER_HOST
|
||||
@ -292,7 +292,7 @@ spec:
|
||||
- name: API_SERVER_PORT
|
||||
value: "8642"
|
||||
- name: API_SERVER_CORS_ORIGINS
|
||||
value: https://triage.hermes.bstein.dev
|
||||
value: https://triage.bstein.dev
|
||||
- name: VICTORIA_METRICS_URL
|
||||
value: http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
|
||||
- name: ARIADNE_BASE_URL
|
||||
@ -405,7 +405,7 @@ spec:
|
||||
- {name: HERMES_WEBUI_GATEWAY_USE_RUNS_API, value: "true"}
|
||||
- {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"}
|
||||
- {name: HERMES_WEBUI_SECURE, value: "1"}
|
||||
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://triage.hermes.bstein.dev}
|
||||
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://triage.bstein.dev}
|
||||
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
|
||||
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
|
||||
- {name: HERMES_ROUTER_PROFILE, value: triage}
|
||||
|
||||
@ -18,7 +18,9 @@ spec:
|
||||
- name: cli-lane-runner
|
||||
env:
|
||||
- {name: HERMES_CLI_LANE_OWNED_WORKSPACES_ONLY, value: "true"}
|
||||
- {name: HERMES_CLI_LANE_CONCURRENCY, value: "1"}
|
||||
# Cap simultaneous direct cli-* Codex/Claude runner slots separately
|
||||
# from the normal Hermes profile cap in agent-configmap.yaml.
|
||||
- {name: HERMES_CLI_LANE_CONCURRENCY, value: "2"}
|
||||
- name: execution-pool-coordinator
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
@ -82,7 +82,7 @@ spec:
|
||||
args:
|
||||
- --provider=oidc
|
||||
- --config=/vault/secrets/oidc-config
|
||||
- --redirect-url=https://triage.hermes.bstein.dev/oauth2/callback
|
||||
- --redirect-url=https://triage.bstein.dev/oauth2/callback
|
||||
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
|
||||
- --user-id-claim=sub
|
||||
- --code-challenge-method=S256
|
||||
@ -181,7 +181,7 @@ spec:
|
||||
args:
|
||||
- --provider=oidc
|
||||
- --config=/vault/secrets/oidc-config
|
||||
- --redirect-url=https://chat.hermes.bstein.dev/oauth2/callback
|
||||
- --redirect-url=https://chat.bstein.dev/oauth2/callback
|
||||
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
|
||||
- --user-id-claim=sub
|
||||
- --code-challenge-method=S256
|
||||
|
||||
@ -5,8 +5,11 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -163,18 +166,24 @@ func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
|
||||
}
|
||||
asset := response.Body.String()
|
||||
for _, expected := range []string{
|
||||
"aria-live", "aria-busy", "/api/sessions/", "/messages?limit=24&hermes_fallback=1",
|
||||
"aria-live", "aria-busy", "/api/session?session_id=",
|
||||
"&messages=1&msg_limit=24", "hermes_fallback=1",
|
||||
"Latest stored activity", "no renderable messages",
|
||||
"Session updates disconnected", "oauth2/start?rd=", "payload.session_id",
|
||||
"session.ended_at == null", "latest.observed", "addEventListener('online'",
|
||||
"addEventListener('pageshow'", "schedule(document.hidden", "fetch(",
|
||||
"Session updates disconnected", "oauth2/start?rd=", "payload.session",
|
||||
"session_profile_mismatch", "session.is_streaming", "latest.observed",
|
||||
"addEventListener('online'", "addEventListener('pageshow'",
|
||||
"schedule(document.hidden", "fetch(",
|
||||
} {
|
||||
if !strings.Contains(asset, expected) {
|
||||
t.Fatalf("session fallback omitted %q", expected)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"location.replace", "location.reload", "history.replaceState", "/api/session?", "WebSocket",
|
||||
"location.replace", "location.reload", "history.replaceState", "WebSocket",
|
||||
// `/api/sessions/<id>` belongs to the Hermes agent dashboard, not to the
|
||||
// tenant WebUI this router proxies. Polling it 404s on every request and
|
||||
// renders a false ownership error after each full page load.
|
||||
"/api/sessions/",
|
||||
} {
|
||||
if strings.Contains(asset, forbidden) {
|
||||
t.Fatalf("session fallback interferes with native continuity via %q", forbidden)
|
||||
@ -182,6 +191,227 @@ func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// tenantWebUI mirrors the dispatch contract of the deployed Hermes WebUI
|
||||
// (`hermes-webui` server.py): `GET /api/session?session_id=` is the only
|
||||
// session read route, an unrouted path falls through to a generic 404, and an
|
||||
// unknown session id is reported as "Session not found".
|
||||
type tenantWebUI struct {
|
||||
server *httptest.Server
|
||||
sessions map[string]bool
|
||||
identities []string
|
||||
requests int
|
||||
cookies []string
|
||||
}
|
||||
|
||||
func newTenantWebUI(t *testing.T, marker string, sessions ...string) *tenantWebUI {
|
||||
t.Helper()
|
||||
backend := &tenantWebUI{sessions: map[string]bool{}}
|
||||
for _, id := range sessions {
|
||||
backend.sessions[id] = true
|
||||
}
|
||||
backend.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
backend.requests++
|
||||
backend.identities = append(backend.identities, request.Header.Get(trustedTenantHeader))
|
||||
backend.cookies = append(backend.cookies, request.Header.Get("Cookie"))
|
||||
for _, leaked := range []string{"X-Forwarded-User", "X-Auth-Request-User", "Authorization"} {
|
||||
if request.Header.Get(leaked) != "" {
|
||||
t.Errorf("external identity header %q reached a tenant backend", leaked)
|
||||
}
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
if request.URL.Path != "/api/session" {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(writer, `{"error":"not found"}`)
|
||||
return
|
||||
}
|
||||
id := request.URL.Query().Get("session_id")
|
||||
if !backend.sessions[id] {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(writer, `{"error":"Session not found"}`)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
|
||||
"session_id": id,
|
||||
"title": marker,
|
||||
"messages": []map[string]any{{"role": "assistant", "content": marker}},
|
||||
"message_count": 1,
|
||||
"is_streaming": false,
|
||||
}})
|
||||
}))
|
||||
t.Cleanup(backend.server.Close)
|
||||
return backend
|
||||
}
|
||||
|
||||
// continuityPollPath derives the request the injected fallback actually issues,
|
||||
// so a regression in the polled contract fails these tests instead of silently
|
||||
// reintroducing the permanent 404.
|
||||
func continuityPollPath(t *testing.T, router *tenantRouter, sessionID string) string {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil)
|
||||
request.Header.Set("X-Forwarded-User", "asset-reader")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("continuity asset got status %d", response.Code)
|
||||
}
|
||||
matcher := regexp.MustCompile(`'(/api/[^']*)' \+ encodeURIComponent\(sessionId\) \+\s*'([^']*)'`)
|
||||
parts := matcher.FindStringSubmatch(response.Body.String())
|
||||
if parts == nil {
|
||||
t.Fatal("continuity fallback does not build a session-scoped poll URL")
|
||||
}
|
||||
return parts[1] + url.QueryEscape(sessionID) + parts[2]
|
||||
}
|
||||
|
||||
func continuityPoll(t *testing.T, router *tenantRouter, subject, cookie, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.Header.Set("X-Forwarded-User", subject)
|
||||
request.Header.Set("X-Auth-Request-User", subject)
|
||||
if cookie != "" {
|
||||
request.Header.Set("Cookie", cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
// Reproduction of the reported failure. The tenant WebUI image
|
||||
// (registry.bstein.dev/bstein/hermes-webui@sha256:c276a9e1…) routes exactly one
|
||||
// session read, `GET /api/session?session_id=`; `/api/sessions/<id>[/messages]`
|
||||
// belongs to the separate Hermes agent dashboard and falls through to
|
||||
// server.py's generic 404. Polling it could never succeed for anyone, so the
|
||||
// banner fired on every full page load rather than on a real ownership problem.
|
||||
func TestLegacyDashboardSessionPollAlwaysMissesTheTenantWebUI(t *testing.T) {
|
||||
const sessionID = "sess-1"
|
||||
backend := newTenantWebUI(t, "brad-private-session", sessionID)
|
||||
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1,
|
||||
func(int) string { return backend.server.URL })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, legacy := range []string{
|
||||
"/api/sessions/" + sessionID,
|
||||
"/api/sessions/" + sessionID + "/messages?limit=24&hermes_fallback=1",
|
||||
} {
|
||||
response := continuityPoll(t, router, "keycloak-subject-brad", "", legacy)
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s: got %d, want the WebUI's 404 for an unrouted path", legacy, response.Code)
|
||||
}
|
||||
}
|
||||
if response := continuityPoll(t, router, "keycloak-subject-brad", "",
|
||||
continuityPollPath(t, router, sessionID)); response.Code != http.StatusOK {
|
||||
t.Fatalf("the shipped fallback still misses the tenant WebUI: got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The Keycloak logout/login round-trip returns the browser to /session/<id> as
|
||||
// a full document load, which is the only moment the injected fallback runs.
|
||||
// The durable session must still resolve for its stable owner.
|
||||
func TestSessionContinuityPollSurvivesLogoutAndRelogin(t *testing.T) {
|
||||
const subject = "keycloak-subject-brad"
|
||||
const sessionID = "9048e2a574d1"
|
||||
backend := newTenantWebUI(t, "brad-private-session", sessionID)
|
||||
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 4, func(slot int) string {
|
||||
if slot == 0 {
|
||||
return backend.server.URL
|
||||
}
|
||||
return "http://127.0.0.1:1"
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pollPath := continuityPollPath(t, router, sessionID)
|
||||
|
||||
before := continuityPoll(t, router, subject,
|
||||
"__Host-hermes_chat=first-sso-session; "+tenantSessionCookie+"=webui-1", pollPath)
|
||||
if before.Code != http.StatusOK {
|
||||
t.Fatalf("first visit could not read its own session: got %d, want 200", before.Code)
|
||||
}
|
||||
|
||||
// Logout invalidates the Keycloak session, so the browser returns with a
|
||||
// completely different oauth2-proxy cookie under the same Keycloak subject.
|
||||
after := continuityPoll(t, router, subject,
|
||||
"__Host-hermes_chat=second-sso-session; "+tenantSessionCookie+"=webui-1", pollPath)
|
||||
if after.Code != http.StatusOK {
|
||||
t.Fatalf("relogin lost the durable session: got %d, want 200", after.Code)
|
||||
}
|
||||
var payload struct {
|
||||
Session struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.NewDecoder(after.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Session.SessionID != sessionID || payload.Session.Title != "brad-private-session" {
|
||||
t.Fatalf("relogin resolved the wrong session: %#v", payload.Session)
|
||||
}
|
||||
if len(backend.identities) != 2 || backend.identities[0] != "slot-0" || backend.identities[1] != "slot-0" {
|
||||
t.Fatalf("relogin did not keep a stable tenant identity: %#v", backend.identities)
|
||||
}
|
||||
for _, cookie := range backend.cookies {
|
||||
if strings.Contains(cookie, "__Host-hermes_chat") {
|
||||
t.Fatalf("the Keycloak session cookie crossed into a tenant: %q", cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repairing continuity must not turn the poll into a cross-tenant read.
|
||||
func TestSessionContinuityNeverExposesAnotherSubjectsSession(t *testing.T) {
|
||||
const ownerSession = "owner-session-id"
|
||||
owner := newTenantWebUI(t, "owner-private-marker", ownerSession)
|
||||
intruder := newTenantWebUI(t, "intruder-private-marker")
|
||||
backends := []*tenantWebUI{owner, intruder}
|
||||
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 2, func(slot int) string {
|
||||
return backends[slot].server.URL
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownerSlot, err := router.slotFor("keycloak-owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intruderSlot, err := router.slotFor("keycloak-intruder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ownerSlot == intruderSlot {
|
||||
t.Fatal("two Keycloak subjects shared one isolated slot")
|
||||
}
|
||||
pollPath := continuityPollPath(t, router, ownerSession)
|
||||
|
||||
if response := continuityPoll(t, router, "keycloak-owner",
|
||||
tenantSessionCookie+"=owner-webui", pollPath); response.Code != http.StatusOK {
|
||||
t.Fatalf("the owner lost its own session: got %d", response.Code)
|
||||
}
|
||||
ownerRequests := backends[ownerSlot].requests
|
||||
|
||||
// The intruder replays the owner's session id, the owner's WebUI cookie and
|
||||
// a forged tenant assertion for the owner's slot.
|
||||
request := httptest.NewRequest(http.MethodGet, pollPath, nil)
|
||||
request.Header.Set("X-Forwarded-User", "keycloak-intruder")
|
||||
request.Header.Set("Cookie", tenantSessionCookie+"=owner-webui")
|
||||
request.Header.Set(trustedTenantHeader, "slot-"+strconv.Itoa(ownerSlot))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-subject session read got %d, want 404", response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "owner-private-marker") {
|
||||
t.Fatalf("another subject's session leaked: %s", response.Body.String())
|
||||
}
|
||||
if backends[ownerSlot].requests != ownerRequests {
|
||||
t.Fatal("a forged tenant assertion reached another subject's backend")
|
||||
}
|
||||
identities := backends[intruderSlot].identities
|
||||
if len(identities) == 0 || identities[len(identities)-1] != "slot-"+strconv.Itoa(intruderSlot) {
|
||||
t.Fatalf("the router did not overwrite the forged tenant identity: %#v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
messages := make([]map[string]int, 30)
|
||||
@ -189,9 +419,9 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
|
||||
messages[index] = map[string]int{"index": index}
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{
|
||||
"session_id": "resolved", "messages": messages,
|
||||
})
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
|
||||
"session_id": "resolved", "messages": messages, "message_count": 30,
|
||||
}})
|
||||
}))
|
||||
defer backend.Close()
|
||||
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return backend.URL })
|
||||
@ -200,7 +430,7 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/sessions/root/messages?limit=24&hermes_fallback=1",
|
||||
continuityPollPath(t, router, "root"),
|
||||
nil,
|
||||
)
|
||||
request.Header.Set("X-Forwarded-User", "subject")
|
||||
@ -209,11 +439,8 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d", response.Code)
|
||||
}
|
||||
var payload sessionSnapshot
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Messages) != sessionSnapshotItems || payload.TotalMessages != 30 {
|
||||
payload := decodeSnapshot(t, response.Result())
|
||||
if len(payload.Messages) != sessionSnapshotItems || payload.MessageCount != 30 {
|
||||
t.Fatalf("router returned unbounded fallback: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
package main
|
||||
|
||||
// The chat router proxies browser traffic to the tenant Hermes WebUI, whose
|
||||
// session read contract is `GET /api/session?session_id=<id>`. The dashboard
|
||||
// style `/api/sessions/<id>[/messages]` routes belong to the separate Hermes
|
||||
// agent deployment (services/hermes/scripts/patch_web_session_activity.py) and
|
||||
// are unrouted here, so polling them returned the WebUI's generic 404 on every
|
||||
// attempt and rendered a false "unavailable to this account" banner after every
|
||||
// full page load — exactly what a Keycloak logout/login round-trip produces.
|
||||
const sessionFallbackPath = "/api/session"
|
||||
|
||||
const sessionContinuityJS = `(() => {
|
||||
const match = location.pathname.match(/^\/session\/([^/]+)\/?$/);
|
||||
if (!match) return;
|
||||
@ -41,15 +50,27 @@ const sessionContinuityJS = `(() => {
|
||||
if (latest.observed && typeof latest.content === 'string') return latest.content.slice(0, 120);
|
||||
return latest.role === 'assistant' ? 'assistant update' : latest.role === 'user' ? 'request stored' : 'working';
|
||||
};
|
||||
const authOrMissing = (response) => {
|
||||
// Only the WebUI's own answers decide what the banner claims. A 409 is the
|
||||
// single case where the stored session really is out of this account's
|
||||
// active scope; a 404 means the conversation is no longer stored at all.
|
||||
const handled = async (response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const rd = location.pathname + location.search + location.hash;
|
||||
location.assign('/oauth2/start?rd=' + encodeURIComponent(rd));
|
||||
return 'auth';
|
||||
}
|
||||
if (response.status === 409) {
|
||||
let payload = {};
|
||||
try { payload = await response.json(); } catch (_) { payload = {}; }
|
||||
show(payload.code === 'session_profile_mismatch'
|
||||
? 'This session belongs to a different profile on this account. Switch profiles to reopen it.'
|
||||
: 'This session is unavailable to this account.', false, true);
|
||||
schedule(15000);
|
||||
return 'scoped';
|
||||
}
|
||||
if (response.status === 404) {
|
||||
show('This session is unavailable to this account.', false, true);
|
||||
schedule(10000);
|
||||
show('This conversation is no longer stored in your private chat.', false, true);
|
||||
schedule(15000);
|
||||
return 'missing';
|
||||
}
|
||||
return '';
|
||||
@ -63,28 +84,25 @@ const sessionContinuityJS = `(() => {
|
||||
request = new AbortController();
|
||||
const timeout = window.setTimeout(() => request && request.abort(), 8000);
|
||||
try {
|
||||
const root = '/api/sessions/' + encodeURIComponent(sessionId);
|
||||
const messageResponse = await fetch(root + '/messages?limit=24&hermes_fallback=1', {cache:'no-store', credentials:'same-origin', signal:request.signal});
|
||||
if (authOrMissing(messageResponse)) return;
|
||||
if (!messageResponse.ok) throw new Error('session messages poll failed');
|
||||
const payload = await messageResponse.json();
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
const resolvedId = typeof payload.session_id === 'string' && payload.session_id ? payload.session_id : sessionId;
|
||||
const detailResponse = await fetch('/api/sessions/' + encodeURIComponent(resolvedId), {cache:'no-store', credentials:'same-origin', signal:request.signal});
|
||||
if (authOrMissing(detailResponse)) return;
|
||||
if (!detailResponse.ok) throw new Error('session detail poll failed');
|
||||
const session = await detailResponse.json();
|
||||
const lastActive = Number(session.last_active || session.started_at || 0);
|
||||
const durableWorker = session.source === 'api_server' && Boolean(session.parent_session_id);
|
||||
const recentlyInteractive = Number.isFinite(lastActive) && Date.now() / 1000 - lastActive < 300;
|
||||
const active = session.ended_at == null && messages.length > 0 && (durableWorker || recentlyInteractive);
|
||||
if (active) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false);
|
||||
else if (!messages.length && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true);
|
||||
const query = '/api/session?session_id=' + encodeURIComponent(sessionId) +
|
||||
'&messages=1&msg_limit=24&resolve_model=0&hermes_fallback=1';
|
||||
const response = await fetch(query, {cache:'no-store', credentials:'same-origin', signal:request.signal});
|
||||
if (await handled(response)) return;
|
||||
if (!response.ok) throw new Error('session poll failed');
|
||||
const payload = await response.json();
|
||||
const session = payload && typeof payload.session === 'object' && payload.session ? payload.session : {};
|
||||
const messages = Array.isArray(session.messages) ? session.messages : [];
|
||||
const stored = Number(session.message_count);
|
||||
const count = Number.isFinite(stored) && stored > 0 ? stored : messages.length;
|
||||
const working = Boolean(session.is_streaming) || Boolean(session.active_stream_id) ||
|
||||
Boolean(session.has_pending_user_message);
|
||||
if (working) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false);
|
||||
else if (!count && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true);
|
||||
else hide();
|
||||
schedule(document.hidden ? 10000 : 2500);
|
||||
schedule(document.hidden ? 15000 : 3000);
|
||||
} catch (_) {
|
||||
show('Session updates disconnected. Retrying without changing this session…', true, false);
|
||||
schedule(document.hidden ? 10000 : 3000);
|
||||
schedule(document.hidden ? 15000 : 3000);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
request = null;
|
||||
|
||||
@ -14,21 +14,18 @@ const (
|
||||
sessionSnapshotBytes = 8 << 20
|
||||
)
|
||||
|
||||
type sessionSnapshot struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Messages []json.RawMessage `json:"messages"`
|
||||
TotalMessages int `json:"total_messages"`
|
||||
}
|
||||
|
||||
// boundSessionSnapshot caps only the continuity fallback response. Native
|
||||
// WebUI requests remain untouched, including when an older backend ignores
|
||||
// its optional `limit` query parameter.
|
||||
// its optional `msg_limit` query parameter. The tenant WebUI answers with
|
||||
// {"session": {..., "messages": [...], "message_count": N}}, so the envelope
|
||||
// is decoded field-by-field: every key other than the message tail is relayed
|
||||
// verbatim rather than re-serialized from a fixed struct, which would silently
|
||||
// drop session metadata the poller and future WebUI releases depend on.
|
||||
func boundSessionSnapshot(response *http.Response) error {
|
||||
request := response.Request
|
||||
if request == nil || response.StatusCode != http.StatusOK ||
|
||||
request.URL.Query().Get("hermes_fallback") != "1" ||
|
||||
!strings.HasPrefix(request.URL.Path, "/api/sessions/") ||
|
||||
!strings.HasSuffix(request.URL.Path, "/messages") {
|
||||
request.URL.Path != sessionFallbackPath {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, sessionSnapshotBytes+1))
|
||||
@ -39,19 +36,7 @@ func boundSessionSnapshot(response *http.Response) error {
|
||||
if len(body) > sessionSnapshotBytes {
|
||||
return errors.New("session snapshot exceeds safe response limit")
|
||||
}
|
||||
var payload sessionSnapshot
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.Messages == nil {
|
||||
return errors.New("session snapshot is malformed")
|
||||
}
|
||||
total := len(payload.Messages)
|
||||
if payload.TotalMessages > total {
|
||||
total = payload.TotalMessages
|
||||
}
|
||||
if len(payload.Messages) > sessionSnapshotItems {
|
||||
payload.Messages = payload.Messages[len(payload.Messages)-sessionSnapshotItems:]
|
||||
}
|
||||
payload.TotalMessages = total
|
||||
body, err = json.Marshal(payload)
|
||||
body, err = boundSessionSnapshotBody(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -63,3 +48,49 @@ func boundSessionSnapshot(response *http.Response) error {
|
||||
response.Header.Del("ETag")
|
||||
return nil
|
||||
}
|
||||
|
||||
// boundSessionSnapshotBody trims the message tail of one WebUI session payload
|
||||
// while preserving the total the backend reported.
|
||||
func boundSessionSnapshotBody(body []byte) ([]byte, error) {
|
||||
malformed := errors.New("session snapshot is malformed")
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, malformed
|
||||
}
|
||||
rawSession, ok := envelope["session"]
|
||||
if !ok {
|
||||
return nil, malformed
|
||||
}
|
||||
var session map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawSession, &session); err != nil {
|
||||
return nil, malformed
|
||||
}
|
||||
var messages []json.RawMessage
|
||||
if raw, ok := session["messages"]; ok {
|
||||
if err := json.Unmarshal(raw, &messages); err != nil {
|
||||
return nil, malformed
|
||||
}
|
||||
}
|
||||
total := len(messages)
|
||||
if raw, ok := session["message_count"]; ok {
|
||||
var count int
|
||||
if err := json.Unmarshal(raw, &count); err == nil && count > total {
|
||||
total = count
|
||||
}
|
||||
}
|
||||
if len(messages) > sessionSnapshotItems {
|
||||
messages = messages[len(messages)-sessionSnapshotItems:]
|
||||
}
|
||||
trimmed, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session["messages"] = trimmed
|
||||
session["message_count"] = json.RawMessage(strconv.Itoa(total))
|
||||
rawSession, err = json.Marshal(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelope["session"] = rawSession
|
||||
return json.Marshal(envelope)
|
||||
}
|
||||
|
||||
@ -10,6 +10,20 @@ import (
|
||||
"testing/iotest"
|
||||
)
|
||||
|
||||
// webuiSession mirrors the fields of the tenant WebUI `GET /api/session`
|
||||
// payload that the continuity fallback reads.
|
||||
type webuiSession struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Messages []json.RawMessage `json:"messages"`
|
||||
MessageCount int `json:"message_count"`
|
||||
Title string `json:"title,omitempty"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
}
|
||||
|
||||
type webuiSessionEnvelope struct {
|
||||
Session webuiSession `json:"session"`
|
||||
}
|
||||
|
||||
func snapshotResponse(target, body string) *http.Response {
|
||||
request, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
return &http.Response{
|
||||
@ -20,27 +34,33 @@ func snapshotResponse(target, body string) *http.Response {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeSnapshot(t *testing.T, response *http.Response) webuiSession {
|
||||
t.Helper()
|
||||
var envelope webuiSessionEnvelope
|
||||
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return envelope.Session
|
||||
}
|
||||
|
||||
func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) {
|
||||
messages := make([]map[string]int, 30)
|
||||
for index := range messages {
|
||||
messages[index] = map[string]int{"index": index}
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"session_id": "resolved", "messages": messages,
|
||||
})
|
||||
body, _ := json.Marshal(map[string]any{"session": map[string]any{
|
||||
"session_id": "resolved", "messages": messages, "message_count": 30,
|
||||
}})
|
||||
response := snapshotResponse(
|
||||
"http://tenant/api/sessions/root/messages?limit=24&hermes_fallback=1",
|
||||
"http://tenant/api/session?session_id=root&messages=1&msg_limit=24&hermes_fallback=1",
|
||||
string(body),
|
||||
)
|
||||
response.Header.Set("ETag", "stale")
|
||||
if err := boundSessionSnapshot(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bounded sessionSnapshot
|
||||
if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bounded.Messages) != 24 || bounded.TotalMessages != 30 {
|
||||
bounded := decodeSnapshot(t, response)
|
||||
if len(bounded.Messages) != sessionSnapshotItems || bounded.MessageCount != 30 {
|
||||
t.Fatalf("snapshot was not bounded: %#v", bounded)
|
||||
}
|
||||
var first map[string]int
|
||||
@ -54,29 +74,59 @@ func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) {
|
||||
|
||||
func TestBoundSessionSnapshotPreservesLargerReportedTotal(t *testing.T) {
|
||||
response := snapshotResponse(
|
||||
"http://tenant/api/sessions/root/messages?hermes_fallback=1",
|
||||
`{"session_id":"leaf","messages":[],"total_messages":100}`,
|
||||
"http://tenant/api/session?session_id=leaf&hermes_fallback=1",
|
||||
`{"session":{"session_id":"leaf","messages":[],"message_count":100}}`,
|
||||
)
|
||||
if err := boundSessionSnapshot(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bounded sessionSnapshot
|
||||
if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil {
|
||||
bounded := decodeSnapshot(t, response)
|
||||
if bounded.SessionID != "leaf" || bounded.MessageCount != 100 {
|
||||
t.Fatalf("reported total was lost: %#v", bounded)
|
||||
}
|
||||
}
|
||||
|
||||
// The poller and the WebUI both evolve; bounding the message tail must never
|
||||
// strip the surrounding session metadata that decides what the banner says.
|
||||
func TestBoundSessionSnapshotRelaysUnknownSessionMetadata(t *testing.T) {
|
||||
response := snapshotResponse(
|
||||
"http://tenant/api/session?session_id=root&hermes_fallback=1",
|
||||
`{"session":{"session_id":"root","messages":[{"role":"user"}],`+
|
||||
`"is_streaming":true,"active_stream_id":"stream-1","read_only":false,`+
|
||||
`"future_field":{"kept":true}},"other_envelope_key":7}`,
|
||||
)
|
||||
if err := boundSessionSnapshot(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bounded.SessionID != "leaf" || bounded.TotalMessages != 100 {
|
||||
t.Fatalf("reported total was lost: %#v", bounded)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"is_streaming":true`, `"active_stream_id":"stream-1"`,
|
||||
`"future_field":{"kept":true}`, `"other_envelope_key":7`,
|
||||
} {
|
||||
if !strings.Contains(string(body), expected) {
|
||||
t.Fatalf("bounded snapshot dropped %s: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if length := response.Header.Get("Content-Length"); length != "" &&
|
||||
length != strings.TrimSpace(length) {
|
||||
t.Fatal("content length was not rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"malformed": `{`,
|
||||
"oversized": strings.Repeat("x", sessionSnapshotBytes+1),
|
||||
"malformed": `{`,
|
||||
"missing": `{"error":"Session not found"}`,
|
||||
"nonObject": `{"session":42}`,
|
||||
"badMessageList": `{"session":{"messages":"all of them"}}`,
|
||||
"oversized": strings.Repeat("x", sessionSnapshotBytes+1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
response := snapshotResponse(
|
||||
"http://tenant/api/sessions/root/messages?hermes_fallback=1",
|
||||
"http://tenant/api/session?session_id=root&hermes_fallback=1",
|
||||
body,
|
||||
)
|
||||
if err := boundSessionSnapshot(response); err == nil {
|
||||
@ -85,7 +135,7 @@ func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T)
|
||||
})
|
||||
}
|
||||
response := snapshotResponse(
|
||||
"http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`,
|
||||
"http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`,
|
||||
)
|
||||
response.Body = io.NopCloser(iotest.ErrReader(errors.New("read failed")))
|
||||
if err := boundSessionSnapshot(response); err == nil {
|
||||
@ -96,15 +146,18 @@ func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T)
|
||||
func TestBoundSessionSnapshotLeavesNativeAndErrorResponsesUntouched(t *testing.T) {
|
||||
for _, response := range []*http.Response{
|
||||
{StatusCode: http.StatusOK},
|
||||
snapshotResponse("http://tenant/api/sessions/root/messages", `{}`),
|
||||
snapshotResponse("http://tenant/api/sessions/root", `{}`),
|
||||
// The WebUI's own session reads carry no fallback marker.
|
||||
snapshotResponse("http://tenant/api/session?session_id=root", `{}`),
|
||||
snapshotResponse("http://tenant/api/sessions", `{}`),
|
||||
// The dashboard-only route is not this backend's contract.
|
||||
snapshotResponse("http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`),
|
||||
} {
|
||||
if err := boundSessionSnapshot(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
errorResponse := snapshotResponse(
|
||||
"http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`,
|
||||
"http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`,
|
||||
)
|
||||
errorResponse.StatusCode = http.StatusNotFound
|
||||
if err := boundSessionSnapshot(errorResponse); err != nil {
|
||||
|
||||
@ -249,7 +249,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
|
||||
command, args := commandParts(message.Text)
|
||||
if command == "start" || command == "link" {
|
||||
if len(args) == 0 {
|
||||
_ = bot.sendText(message.Chat.ID, "Sign in to chat.hermes.bstein.dev, open Telegram, and create a one-time link code.")
|
||||
_ = bot.sendText(message.Chat.ID, "Sign in to chat.bstein.dev, open Telegram, and create a one-time link code.")
|
||||
return
|
||||
}
|
||||
if _, err := bot.router.consumeLink(userID, args[0]); err != nil {
|
||||
@ -273,7 +273,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
|
||||
}
|
||||
slot, linked := bot.router.telegramSlot(userID)
|
||||
if !linked {
|
||||
_ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.hermes.bstein.dev and open Telegram to connect it.")
|
||||
_ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.bstein.dev and open Telegram to connect it.")
|
||||
return
|
||||
}
|
||||
if command == "topic" {
|
||||
|
||||
@ -470,7 +470,7 @@ func injectChatBridge(response *http.Response) error {
|
||||
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script></body>`, 1)
|
||||
}
|
||||
if !strings.Contains(content, "hermes-session-continuity.js") {
|
||||
content = strings.Replace(content, "</body>", `<script src="/hermes-session-continuity.js?v=20260817-v1" defer></script></body>`, 1)
|
||||
content = strings.Replace(content, "</body>", `<script src="/hermes-session-continuity.js?v=20260820-webui-session-contract" defer></script></body>`, 1)
|
||||
}
|
||||
response.Body = io.NopCloser(strings.NewReader(content))
|
||||
response.ContentLength = int64(len(content))
|
||||
|
||||
@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from cli_lane_board import _board_call
|
||||
from cli_lane_config import ProcessResult, Route
|
||||
from cli_lane_config import EFFORTS, ProcessResult, Route
|
||||
from cli_lane_health import classify_capacity_failure, record_provider_failure
|
||||
from cli_lane_metrics import (
|
||||
record_provider_fallback,
|
||||
@ -117,20 +117,47 @@ def capacity_failover(
|
||||
+ "\n\nRouting boundary: the first provider failed from capacity/authentication. "
|
||||
+ "Select the alternate hosted provider at an appropriate effort."
|
||||
)
|
||||
alternate = "claude" if route.provider == "codex" else "codex"
|
||||
fallback = _routed_or_blocked(
|
||||
kanban_db,
|
||||
board,
|
||||
task_id,
|
||||
run_id,
|
||||
lambda: select_route(retry_context, f"cli-{alternate}-{route.effort}"),
|
||||
lambda: select_route(
|
||||
retry_context,
|
||||
assignee,
|
||||
exclude_provider=route.provider,
|
||||
exclude_reason=f"hit a {failure_class} failure at this boundary",
|
||||
),
|
||||
)
|
||||
if fallback is None:
|
||||
return FailoverOutcome(route, result, candidate_file, route.provider, True)
|
||||
if EFFORTS.index(fallback.effort) < EFFORTS.index(route.effort):
|
||||
# Never let a capacity-triggered reclassification downgrade effort:
|
||||
# re-pin the classifier's chosen (healthy) provider at the original
|
||||
# floor, whether the downgrade came from the classifier itself or
|
||||
# from its own excluded-provider health guard.
|
||||
preserved = _routed_or_blocked(
|
||||
kanban_db,
|
||||
board,
|
||||
task_id,
|
||||
run_id,
|
||||
lambda: select_route(
|
||||
retry_context, f"cli-{fallback.provider}-{route.effort}"
|
||||
),
|
||||
)
|
||||
if preserved is None:
|
||||
return FailoverOutcome(route, result, candidate_file, route.provider, True)
|
||||
comment(
|
||||
f"Effort preserved: Switchyard classification chose {fallback.effort} "
|
||||
f"for {fallback.provider}; escalated to the original {route.effort} "
|
||||
"floor so capacity failover never downgrades a safety task.",
|
||||
)
|
||||
fallback = preserved
|
||||
record_provider_fallback(route.provider, fallback.provider, failure_class)
|
||||
comment(
|
||||
f"Provider fallback: {route.provider} -> {fallback.provider} "
|
||||
f"after a {failure_class} failure; Jetson reclassified the retry boundary.",
|
||||
f"after a {failure_class} failure; Jetson reclassified the retry boundary "
|
||||
f"(classifier={fallback.classifier}).",
|
||||
)
|
||||
fallback_result = run_provider(
|
||||
fallback,
|
||||
|
||||
@ -12,6 +12,34 @@ from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
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"
|
||||
@ -67,9 +95,12 @@ def main() -> None:
|
||||
with urlopen(request, timeout=120) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
transcript = str(result.get("text") or "").strip()
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = args.output_dir / f"{args.input_path.stem}.txt"
|
||||
output.write_text(transcript, encoding="utf-8")
|
||||
_write_result(
|
||||
args.output_dir,
|
||||
args.input_path.stem,
|
||||
transcript,
|
||||
_normalize_language(result.get("language")),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -7,9 +7,9 @@ asserting health, placement, ownership, or current model availability.
|
||||
|
||||
| Surface | Purpose | Identity boundary | State and permissions |
|
||||
| --- | --- | --- | --- |
|
||||
| `triage.hermes.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints |
|
||||
| `triage.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints |
|
||||
| `agent.hermes.bstein.dev` | Brad's project coordinator | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace and separate PVC; native Hermes delegates bounded work while Herdr preserves real Codex and Claude Code CLI sessions when needed |
|
||||
| `chat.hermes.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access |
|
||||
| `chat.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access |
|
||||
|
||||
The instances do not share conversation state, credentials, profiles, skills
|
||||
created on their PVCs, or Kubernetes identities. They share only the inference
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: hermes-access-oidc-client-ensure-10
|
||||
name: hermes-access-oidc-client-ensure-11
|
||||
namespace: sso
|
||||
spec:
|
||||
backoffLimit: 3
|
||||
|
||||
@ -84,10 +84,24 @@ ensure_proxy_client() {
|
||||
client_id="$1"
|
||||
public_url="$2"
|
||||
vault_path="$3"
|
||||
# Optional legacy host kept registered alongside the canonical one during a
|
||||
# hostname rename. Keycloak matches redirect_uri exactly, so dropping the old
|
||||
# entry turns every in-flight login into "Invalid parameter: redirect_uri".
|
||||
legacy_url="${4:-}"
|
||||
if [ -n "${legacy_url}" ]; then
|
||||
origins="$(jq -nc --arg a "${public_url}" --arg b "${legacy_url}" '[$a,$b]')"
|
||||
else
|
||||
origins="$(jq -nc --arg a "${public_url}" '[$a]')"
|
||||
fi
|
||||
redirect_uris="$(printf '%s' "${origins}" | jq -c 'map(. + "/oauth2/callback")')"
|
||||
# Keycloak takes post-logout origins as one "##"-delimited string.
|
||||
post_logout="$(printf '%s' "${origins}" | jq -r 'join("##")')"
|
||||
payload="$(jq -nc \
|
||||
--arg client_id "${client_id}" \
|
||||
--arg redirect_uri "${public_url}/oauth2/callback" \
|
||||
--argjson redirect_uris "${redirect_uris}" \
|
||||
--argjson web_origins "${origins}" \
|
||||
--arg web_origin "${public_url}" \
|
||||
--arg post_logout "${post_logout}" \
|
||||
'{
|
||||
clientId:$client_id,
|
||||
name:$client_id,
|
||||
@ -98,13 +112,13 @@ ensure_proxy_client() {
|
||||
implicitFlowEnabled:false,
|
||||
directAccessGrantsEnabled:false,
|
||||
serviceAccountsEnabled:false,
|
||||
redirectUris:[$redirect_uri],
|
||||
webOrigins:[$web_origin],
|
||||
redirectUris:$redirect_uris,
|
||||
webOrigins:$web_origins,
|
||||
rootUrl:$web_origin,
|
||||
baseUrl:"/",
|
||||
attributes:{
|
||||
"pkce.code.challenge.method":"S256",
|
||||
"post.logout.redirect.uris":$web_origin,
|
||||
"post.logout.redirect.uris":$post_logout,
|
||||
"access.token.lifespan":"1200"
|
||||
}
|
||||
}')"
|
||||
@ -327,8 +341,10 @@ ensure_telegram_config() {
|
||||
}
|
||||
|
||||
ensure_hermes_owner
|
||||
ensure_proxy_client "hermes-chat-proxy" "https://chat.hermes.bstein.dev" "hermes/chat-oidc"
|
||||
ensure_proxy_client "hermes-chat-proxy" "https://chat.bstein.dev" "hermes/chat-oidc" \
|
||||
"https://chat.hermes.bstein.dev"
|
||||
ensure_proxy_client "hermes-agent-proxy" "https://agent.hermes.bstein.dev" "hermes/agent-oidc"
|
||||
ensure_proxy_client "hermes-triage-proxy" "https://triage.hermes.bstein.dev" "hermes/triage-oidc"
|
||||
ensure_proxy_client "hermes-triage-proxy" "https://triage.bstein.dev" "hermes/triage-oidc" \
|
||||
"https://triage.hermes.bstein.dev"
|
||||
ensure_service_account_client "hermes-automation" "hermes/developer-keycloak"
|
||||
ensure_telegram_config
|
||||
|
||||
@ -555,7 +555,7 @@ spec:
|
||||
# open the run that wrote it rather than taking "Proposed by
|
||||
# Hermes" on trust.
|
||||
- name: ARIADNE_HERMES_UI_URL
|
||||
value: https://triage.hermes.bstein.dev
|
||||
value: https://triage.bstein.dev
|
||||
- name: ARIADNE_HERMES_SONAR_ENABLED
|
||||
value: "true"
|
||||
- name: ARIADNE_HERMES_SONAR_URL
|
||||
|
||||
@ -15,8 +15,8 @@ data:
|
||||
METIS_MAX_DEVICE_BYTES: "1000000000000"
|
||||
METIS_NAMESPACE: maintenance
|
||||
METIS_REMOTE_POD_TIMEOUT_SEC: "14400"
|
||||
METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-302-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
|
||||
METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
|
||||
METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
|
||||
METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
|
||||
METIS_HARBOR_REGISTRY: registry.bstein.dev
|
||||
METIS_HARBOR_PROJECT: metis
|
||||
METIS_HARBOR_API_BASE: https://registry.bstein.dev/api/v2.0
|
||||
|
||||
@ -13,6 +13,6 @@ images:
|
||||
- name: registry.bstein.dev/bstein/ariadne
|
||||
newTag: 0.1.0-464 # {"$imagepolicy": "maintenance:ariadne:tag"}
|
||||
- name: registry.bstein.dev/bstein/metis
|
||||
newTag: 0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"}
|
||||
newTag: 0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"}
|
||||
- name: registry.bstein.dev/bstein/soteria
|
||||
newTag: 0.1.0-120 # {"$imagepolicy": "maintenance:soteria:tag"}
|
||||
|
||||
@ -32,7 +32,7 @@ spec:
|
||||
kubernetes.io/arch: amd64
|
||||
containers:
|
||||
- name: metis-sentinel
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-302-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
|
||||
imagePullPolicy: Always
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@ -32,7 +32,7 @@ spec:
|
||||
kubernetes.io/arch: arm64
|
||||
containers:
|
||||
- name: metis-sentinel
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"}
|
||||
image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"}
|
||||
imagePullPolicy: Always
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@ -27,8 +27,8 @@ data:
|
||||
https://money.bstein.dev
|
||||
https://health.bstein.dev
|
||||
https://agent.hermes.bstein.dev
|
||||
https://chat.hermes.bstein.dev
|
||||
https://triage.hermes.bstein.dev
|
||||
https://chat.bstein.dev
|
||||
https://triage.bstein.dev
|
||||
https://cassandra.bstein.dev
|
||||
https://veles.bstein.dev
|
||||
https://matrix.live.bstein.dev
|
||||
|
||||
12
testing/fixtures/hermes-agent-9de9c25f/SOURCE.md
Normal file
12
testing/fixtures/hermes-agent-9de9c25f/SOURCE.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Hermes Agent STT fixture provenance
|
||||
|
||||
This minimal module contains the exact `_prepare_local_audio` patch context
|
||||
extracted from the agent image inherited by `Dockerfile.hermes-webui`:
|
||||
|
||||
- Image: `registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107`
|
||||
- OCI platform manifest: `sha256:9b4c00a25fd08f0df3bcb800d755bd48576fb65af5ab0df40058fbb78a147e43`
|
||||
- Upstream Hermes revision: `9de9c25f620ff7f1ce0fd5457d596052d5159596`
|
||||
- Full upstream `tools/transcription_tools.py` SHA-256: `d7d7df56b98dfadc0a7c08d5addd02db4e3106e6fd3b753e9241c23145eb2951`
|
||||
|
||||
Tests execute the production patcher against this source fragment and then run
|
||||
its real ffmpeg conversion boundary with browser-produced bytes.
|
||||
@ -0,0 +1,44 @@
|
||||
"""Exact pinned local-audio preparation fragment with its direct dependencies."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
|
||||
LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
|
||||
|
||||
def _find_ffmpeg_binary() -> Optional[str]:
|
||||
return shutil.which("ffmpeg")
|
||||
|
||||
|
||||
def windows_hide_flags() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Normalize audio for local CLI STT when needed."""
|
||||
audio_path = Path(file_path)
|
||||
if audio_path.suffix.lower() in LOCAL_NATIVE_AUDIO_FORMATS:
|
||||
return file_path, None
|
||||
|
||||
ffmpeg = _find_ffmpeg_binary()
|
||||
if not ffmpeg:
|
||||
return None, "Local STT fallback requires ffmpeg for non-WAV inputs, but ffmpeg was not found"
|
||||
|
||||
converted_path = os.path.join(work_dir, f"{audio_path.stem}.wav")
|
||||
command = [ffmpeg, "-y", "-i", file_path, converted_path]
|
||||
|
||||
try:
|
||||
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
|
||||
return converted_path, None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("ffmpeg conversion timed out for %s", file_path)
|
||||
return None, "Audio conversion for local STT timed out"
|
||||
except subprocess.CalledProcessError as e:
|
||||
details = e.stderr.strip() or e.stdout.strip() or str(e)
|
||||
logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
|
||||
return None, f"Failed to convert audio for local STT: {details}"
|
||||
28
testing/fixtures/hermes-agent/tools/transcription_tools.py
Normal file
28
testing/fixtures/hermes-agent/tools/transcription_tools.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Pinned local-command STT envelope anchor used by image-patch tests."""
|
||||
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _Logger:
|
||||
def info(self, *args):
|
||||
return None
|
||||
|
||||
|
||||
logger = _Logger()
|
||||
|
||||
|
||||
def _transcribe_local_command(file_path, normalized_model, output_dir):
|
||||
try:
|
||||
with contextlib.nullcontext(output_dir):
|
||||
txt_files = sorted(Path(output_dir).glob("*.txt"))
|
||||
transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
||||
logger.info(
|
||||
"Transcribed %s via local STT command (%s, %d chars)",
|
||||
Path(file_path).name,
|
||||
normalized_model,
|
||||
len(transcript_text),
|
||||
)
|
||||
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
|
||||
except OSError as error:
|
||||
return {"success": False, "transcript": "", "error": str(error)}
|
||||
12
testing/fixtures/hermes-webui-0.52.181/SOURCE.md
Normal file
12
testing/fixtures/hermes-webui-0.52.181/SOURCE.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Hermes WebUI fixture provenance
|
||||
|
||||
These minimal files contain the exact patch-context fragments extracted from the
|
||||
Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
|
||||
|
||||
- Image: `ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2`
|
||||
- Version: `0.52.181`
|
||||
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
|
||||
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
|
||||
|
||||
The fixture stays intentionally narrow, but tests execute the shipped
|
||||
`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic.
|
||||
10
testing/fixtures/hermes-webui-0.52.181/api/config.py
Normal file
10
testing/fixtures/hermes-webui-0.52.181/api/config.py
Normal file
@ -0,0 +1,10 @@
|
||||
_SETTINGS_DEFAULTS = {
|
||||
"tts_voice": "",
|
||||
}
|
||||
_SETTINGS_SPEECH_KEYS = {
|
||||
"tts_voice",
|
||||
}
|
||||
UPSTREAM_VALIDATION_FRAGMENT = ''' if k == "tts_voice":
|
||||
if not isinstance(v, str) or len(v) > 200 or "\x00" in v:
|
||||
continue
|
||||
'''
|
||||
41
testing/fixtures/hermes-webui-0.52.181/api/routes.py
Normal file
41
testing/fixtures/hermes-webui-0.52.181/api/routes.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Pinned Atlas TTS route anchors used by image-patch tests."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from urllib.request import ProxyHandler, Request, build_opener
|
||||
|
||||
|
||||
class _NoRedirectTtsHandler:
|
||||
"""Placeholder for the upstream no-redirect opener handler."""
|
||||
|
||||
|
||||
class _Logger:
|
||||
def exception(self, message):
|
||||
return None
|
||||
|
||||
|
||||
logger = _Logger()
|
||||
|
||||
|
||||
class _Upstream:
|
||||
def read(self):
|
||||
return b"RIFFsynthetic"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
|
||||
def _buffer_tts_audio_response(response):
|
||||
return response.read()
|
||||
|
||||
|
||||
def _tts_open(req, *, timeout=30, opener_factory=None):
|
||||
return _Upstream()
|
||||
|
||||
|
||||
def _handle_tts(handler, data, text, rate_str, engine):
|
||||
# ── ElevenLabs TTS ──────────────────────────────────────────────────
|
||||
return None
|
||||
13
testing/fixtures/hermes-webui-0.52.181/api/upload.py
Normal file
13
testing/fixtures/hermes-webui-0.52.181/api/upload.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""Pinned upstream /api/transcribe response anchor used by image-patch tests."""
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
return {"status": status, "payload": payload}
|
||||
|
||||
|
||||
def handle_transcribe(handler, result):
|
||||
try:
|
||||
transcript = str(result.get('transcript') or '').strip()
|
||||
return j(handler, {'ok': True, 'transcript': transcript})
|
||||
except ValueError as error:
|
||||
return j(handler, {'error': str(error)}, status=400)
|
||||
34
testing/fixtures/hermes-webui-0.52.181/static/boot.js
Normal file
34
testing/fixtures/hermes-webui-0.52.181/static/boot.js
Normal file
@ -0,0 +1,34 @@
|
||||
function speakWithRegisteredEngine(){
|
||||
const _opts={
|
||||
voice: localStorage.getItem("hermes-tts-voice")||'',
|
||||
rate: parseFloat(localStorage.getItem("hermes-tts-rate")),
|
||||
};
|
||||
return _opts;
|
||||
}
|
||||
function speakWithEdge(clean){
|
||||
const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
|
||||
const rate='';
|
||||
const pitch='';
|
||||
return fetch('/api/tts', {
|
||||
body: JSON.stringify({text: clean, voice, rate, pitch})
|
||||
});
|
||||
}
|
||||
function speakWithBrowser(clean){
|
||||
const utter=new SpeechSynthesisUtterance(clean);
|
||||
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||||
const voices=speechSynthesis.getVoices();
|
||||
if(savedVoice&&voices.length){
|
||||
const match=voices.find(v=>v.name===savedVoice);
|
||||
if(match) utter.voice=match;
|
||||
}
|
||||
return utter;
|
||||
}
|
||||
function _mirrorSpeechSettingsFromServer(s){
|
||||
const defaults={
|
||||
tts_voice:'',
|
||||
};
|
||||
[
|
||||
['tts_voice','hermes-tts-voice'],
|
||||
].forEach(([settingKey,storageKey])=>localStorage.setItem(storageKey,s[settingKey]));
|
||||
return defaults;
|
||||
}
|
||||
4
testing/fixtures/hermes-webui-0.52.181/static/i18n.js
Normal file
4
testing/fixtures/hermes-webui-0.52.181/static/i18n.js
Normal file
@ -0,0 +1,4 @@
|
||||
const EN = {
|
||||
settings_label_tts_voice: 'Voice',
|
||||
settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
|
||||
};
|
||||
24
testing/fixtures/hermes-webui-0.52.181/static/index.html
Normal file
24
testing/fixtures/hermes-webui-0.52.181/static/index.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||
</head>
|
||||
<body>
|
||||
<select id="settingsTtsEngine"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select>
|
||||
<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
||||
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="">Default system voice</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
|
||||
</div>
|
||||
<div class="composer-box" id="composerBox">
|
||||
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<button class="icon-btn voice-mode-btn has-tooltip" id="btnVoiceMode" data-tooltip="Voice mode" data-i18n-title="voice_mode_toggle" style="display:none"></button>
|
||||
</div>
|
||||
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
44
testing/fixtures/hermes-webui-0.52.181/static/panels.js
Normal file
44
testing/fixtures/hermes-webui-0.52.181/static/panels.js
Normal file
@ -0,0 +1,44 @@
|
||||
const _SETTINGS_SPEECH_STORAGE_KEYS={
|
||||
tts_engine:'hermes-tts-engine',
|
||||
tts_voice:'hermes-tts-voice',
|
||||
tts_rate:'hermes-tts-rate',
|
||||
};
|
||||
let _settingsSpeechChangedKeys=new Set();
|
||||
|
||||
function _speechPreferencesPayloadFromUi(){
|
||||
const payload={};
|
||||
const ttsVoiceSel=$('settingsTtsVoice');
|
||||
if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function loadSettingsPanel(){
|
||||
const ttsEngineSel=$('settingsTtsEngine');
|
||||
if(ttsEngineSel){
|
||||
ttsEngineSel.onchange=function(){
|
||||
localStorage.setItem('hermes-tts-engine',this.value);
|
||||
window._populateTtsVoices();
|
||||
_schedulePreferencesAutosave();
|
||||
};
|
||||
}
|
||||
// Populate voice selector based on engine
|
||||
const ttsVoiceSel=$('settingsTtsVoice');
|
||||
window._populateTtsVoices=function(){
|
||||
if(!ttsVoiceSel) return;
|
||||
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
||||
const current=String(_speechSetting('tts_voice','hermes-tts-voice','')||'');
|
||||
_syncSpeechPreferenceCache('tts_voice',current);
|
||||
if(engine==='edge'){
|
||||
const edgeVoices=[
|
||||
{value:'en-US-AriaNeural',label:'Aria (English, Female)'},
|
||||
];
|
||||
ttsVoiceSel.innerHTML='<option value="">Default (Xiaoxiao)</option>';
|
||||
edgeVoices.forEach(v=>ttsVoiceSel.appendChild(v));
|
||||
}
|
||||
};
|
||||
if(ttsVoiceSel&&'speechSynthesis' in window){
|
||||
window._populateTtsVoices();
|
||||
ttsVoiceSel.onchange=function(){_markSpeechPreferenceChanged('tts_voice');localStorage.setItem('hermes-tts-voice',this.value);_schedulePreferencesAutosave();};
|
||||
}
|
||||
// TTS rate/pitch sliders
|
||||
}
|
||||
35
testing/fixtures/hermes-webui-0.52.181/static/ui.js
Normal file
35
testing/fixtures/hermes-webui-0.52.181/static/ui.js
Normal file
@ -0,0 +1,35 @@
|
||||
function _buildBrowserUtterance(text, btn){
|
||||
const utter=new SpeechSynthesisUtterance(text);
|
||||
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||||
const voices=speechSynthesis.getVoices();
|
||||
if(savedVoice&&voices.length){
|
||||
const match=voices.find(v=>v.name===savedVoice);
|
||||
if(match) utter.voice=match;
|
||||
}
|
||||
return utter;
|
||||
}
|
||||
function _playEdgeTtsChunked(text, btn){
|
||||
const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
|
||||
return fetch('/api/tts',{body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
|
||||
}
|
||||
function speakSelected(clean, btn, engine){
|
||||
if(engine==='edge'){
|
||||
_playEdgeTtsChunked(clean, btn);
|
||||
}
|
||||
}
|
||||
function speakAutomatically(clean, engine){
|
||||
if(engine==='edge'){
|
||||
_playEdgeTtsChunked(clean, null);
|
||||
}
|
||||
}
|
||||
function registeredTts(engine, clean){
|
||||
const _opts={
|
||||
voice: localStorage.getItem('hermes-tts-voice')||'',
|
||||
rate: parseFloat(localStorage.getItem('hermes-tts-rate')),
|
||||
};
|
||||
const autoOpts={
|
||||
voice: localStorage.getItem('hermes-tts-voice')||'',
|
||||
pitch: parseFloat(localStorage.getItem('hermes-tts-pitch')),
|
||||
};
|
||||
return [engine, clean, _opts, autoOpts];
|
||||
}
|
||||
18
testing/fixtures/mediarecorder/SOURCE.md
Normal file
18
testing/fixtures/mediarecorder/SOURCE.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Chromium MediaRecorder fixture provenance
|
||||
|
||||
`chromium-webm-opus.webm` is the concatenation of ten successive 250 ms-ish
|
||||
data blobs emitted by a real Chromium `MediaRecorder` for
|
||||
`audio/webm;codecs=opus`; `chromium-webm-opus.json` records their exact byte
|
||||
lengths so tests can replay each browser event. The recording has leading
|
||||
silence followed by a 440 Hz signal, mirroring speech that begins after the
|
||||
hands-free VAD pre-roll is full.
|
||||
|
||||
- Browser: Chromium 140.0.7339.16 (Playwright build v1187, arm64)
|
||||
- Generator: `testing/probes/generate_mediarecorder_fixture.js`
|
||||
- Timeslice request: 250 ms
|
||||
- Complete concatenated payload: WebM/Opus, accepted by ffmpeg 7.1.5
|
||||
- Complete payload SHA-256: `5585014ecad185511a727ccf47aa8b1ac70ea6f3efd7778309742bf88e6faf51`
|
||||
- First five chunks are treated as pre-speech by the test model; the production
|
||||
pre-roll retains only the most recent three.
|
||||
|
||||
The fixture is generated data, not recorded speech.
|
||||
20
testing/fixtures/mediarecorder/chromium-webm-opus.json
Normal file
20
testing/fixtures/mediarecorder/chromium-webm-opus.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"mime_type": "audio/webm;codecs=opus",
|
||||
"timeslice_ms": 250,
|
||||
"pre_speech_chunk_count": 5,
|
||||
"chunk_sizes": [
|
||||
204,
|
||||
70,
|
||||
70,
|
||||
70,
|
||||
70,
|
||||
3864,
|
||||
5796,
|
||||
4830,
|
||||
4830,
|
||||
1945
|
||||
],
|
||||
"payload_file": "chromium-webm-opus.webm",
|
||||
"browser": "140.0.7339.16",
|
||||
"generator": "testing/probes/generate_mediarecorder_fixture.js"
|
||||
}
|
||||
BIN
testing/fixtures/mediarecorder/chromium-webm-opus.webm
Normal file
BIN
testing/fixtures/mediarecorder/chromium-webm-opus.webm
Normal file
Binary file not shown.
84
testing/probes/generate_mediarecorder_fixture.js
Normal file
84
testing/probes/generate_mediarecorder_fixture.js
Normal file
@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
async function main() {
|
||||
const outputPath = process.argv[2];
|
||||
if (!outputPath) throw new Error('usage: node generate_mediarecorder_fixture.js OUTPUT.json');
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const fixture = await page.evaluate(async () => {
|
||||
const mimeType = 'audio/webm;codecs=opus';
|
||||
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||
throw new Error(`${mimeType} is not supported by this Chromium build`);
|
||||
}
|
||||
|
||||
const context = new AudioContext({sampleRate: 48000});
|
||||
const destination = context.createMediaStreamDestination();
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.frequency.value = 440;
|
||||
gain.gain.value = 0;
|
||||
oscillator.connect(gain).connect(destination);
|
||||
oscillator.start();
|
||||
|
||||
const chunks = [];
|
||||
const recorder = new MediaRecorder(destination.stream, {mimeType});
|
||||
recorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size) chunks.push(event.data);
|
||||
};
|
||||
const stopped = new Promise(resolve => { recorder.onstop = resolve; });
|
||||
const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
recorder.start(250);
|
||||
await delay(1400);
|
||||
gain.gain.setValueAtTime(0.35, context.currentTime);
|
||||
await delay(900);
|
||||
gain.gain.setValueAtTime(0, context.currentTime);
|
||||
await delay(500);
|
||||
recorder.stop();
|
||||
await stopped;
|
||||
|
||||
oscillator.stop();
|
||||
await context.close();
|
||||
const encoded = [];
|
||||
for (const chunk of chunks) {
|
||||
const bytes = new Uint8Array(await chunk.arrayBuffer());
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
encoded.push(btoa(binary));
|
||||
}
|
||||
return {
|
||||
mime_type: recorder.mimeType,
|
||||
timeslice_ms: 250,
|
||||
pre_speech_chunk_count: 5,
|
||||
chunks_base64: encoded,
|
||||
};
|
||||
});
|
||||
const chunks = fixture.chunks_base64.map(value => Buffer.from(value, 'base64'));
|
||||
const payloadPath = outputPath.replace(/\.json$/i, '.webm');
|
||||
fixture.chunk_sizes = chunks.map(chunk => chunk.length);
|
||||
fixture.payload_file = path.basename(payloadPath);
|
||||
delete fixture.chunks_base64;
|
||||
fixture.browser = await browser.version();
|
||||
fixture.generator = 'testing/probes/generate_mediarecorder_fixture.js';
|
||||
fs.writeFileSync(payloadPath, Buffer.concat(chunks));
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
401
testing/probes/hermes_voice_instrument_probe.js
Normal file
401
testing/probes/hermes_voice_instrument_probe.js
Normal file
@ -0,0 +1,401 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
class StyleDeclaration {
|
||||
constructor() {
|
||||
this.values = new Map();
|
||||
this.display = '';
|
||||
}
|
||||
setProperty(name, value) { this.values.set(name, String(value)); }
|
||||
removeProperty(name) { this.values.delete(name); }
|
||||
getPropertyValue(name) { return this.values.get(name) || ''; }
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.className = '';
|
||||
this.textContent = '';
|
||||
this.value = '';
|
||||
this.dataset = {};
|
||||
this.style = new StyleDeclaration();
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.children = [];
|
||||
this.firstChild = null;
|
||||
}
|
||||
get classList() {
|
||||
const element = this;
|
||||
return {
|
||||
add(name) {
|
||||
const names = new Set(element.className.split(/\s+/).filter(Boolean));
|
||||
names.add(name);
|
||||
element.className = [...names].join(' ');
|
||||
},
|
||||
remove(name) {
|
||||
element.className = element.className.split(/\s+/).filter(value => value && value !== name).join(' ');
|
||||
},
|
||||
contains(name) { return element.className.split(/\s+/).includes(name); },
|
||||
};
|
||||
}
|
||||
addEventListener(type, callback) { this.listeners.set(type, callback); }
|
||||
removeEventListener(type, callback) {
|
||||
if (this.listeners.get(type) === callback) this.listeners.delete(type);
|
||||
}
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) || null; }
|
||||
insertBefore(child) {
|
||||
this.children.unshift(child);
|
||||
this.firstChild = this.children[0];
|
||||
}
|
||||
querySelector(selector) {
|
||||
if (selector === 'option[value="atlas"]') {
|
||||
return this.children.find(child => child.value === 'atlas') || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
click() {
|
||||
const callback = this.listeners.get('click');
|
||||
if (callback) callback({preventDefault() {}, stopImmediatePropagation() {}});
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function boot(scriptPath, reduced) {
|
||||
const elements = new Map();
|
||||
for (const id of ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg', 'settingsTtsEngine', 'voiceInstrumentStyles']) {
|
||||
elements.set(id, new Element(id));
|
||||
}
|
||||
const modeButton = elements.get('btnVoiceMode');
|
||||
const bar = elements.get('voiceModeBar');
|
||||
const indicator = elements.get('voiceModeIndicator');
|
||||
const label = elements.get('voiceModeLabel');
|
||||
const styleLink = elements.get('voiceInstrumentStyles');
|
||||
styleLink.setAttribute('href', 'static/atlas-voice.css?v=test');
|
||||
bar.style.display = 'none';
|
||||
|
||||
let now = 1000;
|
||||
let nextTimer = 1;
|
||||
const intervals = new Map();
|
||||
const timeouts = new Map();
|
||||
const captures = [];
|
||||
const recorders = [];
|
||||
const analysers = [];
|
||||
const assistantRows = [];
|
||||
const toasts = [];
|
||||
const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}};
|
||||
let rejectNextCapture = false;
|
||||
let transcriptResolve;
|
||||
let uploadedFile = null;
|
||||
let sent = 0;
|
||||
let lastAudio = null;
|
||||
|
||||
class FakeDate extends Date {
|
||||
static now() { return now; }
|
||||
}
|
||||
class FakeAnalyser {
|
||||
constructor() { this.fftSize = 0; this.level = 0; }
|
||||
getByteTimeDomainData(samples) {
|
||||
const sample = 128 + Math.round(this.level * 128);
|
||||
samples.fill(sample);
|
||||
}
|
||||
}
|
||||
class FakeAudioContext {
|
||||
createAnalyser() {
|
||||
const analyser = new FakeAnalyser();
|
||||
analysers.push(analyser);
|
||||
return analyser;
|
||||
}
|
||||
createBiquadFilter() { return {type: '', frequency: {value: 0}, Q: {value: 0}, connect() {}}; }
|
||||
createMediaStreamSource() { return {connect() {}}; }
|
||||
close() { return Promise.resolve(); }
|
||||
}
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported() { return true; }
|
||||
constructor(stream, options) {
|
||||
this.stream = stream;
|
||||
this.state = 'inactive';
|
||||
this.mimeType = options?.mimeType || 'audio/webm;codecs=opus';
|
||||
this.ondataavailable = null;
|
||||
this.onstop = null;
|
||||
recorders.push(this);
|
||||
}
|
||||
start() { this.state = 'recording'; }
|
||||
stop() {
|
||||
if (this.state === 'inactive') return;
|
||||
this.state = 'inactive';
|
||||
if (this.onstop) queueMicrotask(() => this.onstop());
|
||||
}
|
||||
}
|
||||
class FakeAudio {
|
||||
constructor() {
|
||||
this.currentTime = 0;
|
||||
this.onended = null;
|
||||
this.onerror = null;
|
||||
this.paused = false;
|
||||
lastAudio = this;
|
||||
}
|
||||
play() { this.paused = false; return Promise.resolve(); }
|
||||
pause() { this.paused = true; }
|
||||
finish() { if (this.onended) this.onended(); }
|
||||
}
|
||||
|
||||
const document = {
|
||||
getElementById(id) { return elements.get(id) || null; },
|
||||
createElement() { return new Element('created'); },
|
||||
querySelectorAll(selector) {
|
||||
assert.equal(selector, '.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
||||
return assistantRows;
|
||||
},
|
||||
};
|
||||
const localValues = new Map();
|
||||
const localStorage = {
|
||||
getItem(key) { return localValues.has(key) ? localValues.get(key) : null; },
|
||||
setItem(key, value) { localValues.set(key, String(value)); },
|
||||
};
|
||||
const stream = {getTracks() { return [{stop() {}}]; }};
|
||||
const navigator = {
|
||||
mediaDevices: {
|
||||
getSupportedConstraints() { return {}; },
|
||||
async getUserMedia() {
|
||||
if (rejectNextCapture) {
|
||||
rejectNextCapture = false;
|
||||
throw new Error('Microphone unavailable');
|
||||
}
|
||||
captures.push(stream);
|
||||
return stream;
|
||||
},
|
||||
},
|
||||
};
|
||||
async function fetch(url, options) {
|
||||
if (url === '/api/transcribe/capability') {
|
||||
return {ok: true, json: async () => ({available: true, provider: 'local_command'})};
|
||||
}
|
||||
if (url === '/api/transcribe') {
|
||||
const file = options?.body?.get('file');
|
||||
assert.ok(file, 'transcription request did not carry a file');
|
||||
uploadedFile = {
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
};
|
||||
return new Promise(resolve => { transcriptResolve = resolve; });
|
||||
}
|
||||
if (url === '/api/tts') {
|
||||
return {ok: true, blob: async () => new Blob(['wave'], {type: 'audio/wav'})};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}
|
||||
const window = {
|
||||
MediaRecorder: FakeMediaRecorder,
|
||||
AudioContext: FakeAudioContext,
|
||||
setInterval(callback) {
|
||||
const id = nextTimer++;
|
||||
intervals.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearInterval(id) { intervals.delete(id); },
|
||||
setTimeout(callback, delay) {
|
||||
const id = nextTimer++;
|
||||
timeouts.set(id, {callback, delay});
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timeouts.delete(id); },
|
||||
matchMedia(query) {
|
||||
assert.equal(query, '(prefers-reduced-motion: reduce)');
|
||||
return reducedMotion;
|
||||
},
|
||||
showToast(message) { toasts.push(message); },
|
||||
autoResize() {},
|
||||
send() { sent += 1; },
|
||||
stopTTS() {},
|
||||
_splitForTTS(text) { return [text]; },
|
||||
_stripForTTS(text) { return text; },
|
||||
URL: {createObjectURL() { return 'blob:voice'; }, revokeObjectURL() {}},
|
||||
};
|
||||
const context = {
|
||||
Audio: FakeAudio,
|
||||
Blob,
|
||||
Date: FakeDate,
|
||||
File,
|
||||
FormData,
|
||||
MediaRecorder: FakeMediaRecorder,
|
||||
URL: window.URL,
|
||||
Uint8Array,
|
||||
clearInterval: window.clearInterval,
|
||||
console,
|
||||
document,
|
||||
fetch,
|
||||
localStorage,
|
||||
navigator,
|
||||
queueMicrotask,
|
||||
S: {busy: false, session: {session_id: 'session-1'}},
|
||||
window,
|
||||
};
|
||||
window.window = window;
|
||||
window.document = document;
|
||||
window.fetch = fetch;
|
||||
window.localStorage = localStorage;
|
||||
window.navigator = navigator;
|
||||
Object.assign(window, {Blob, Date: FakeDate, File, FormData, URL: window.URL, Uint8Array});
|
||||
|
||||
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), context, {filename: scriptPath});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
return {
|
||||
elements, modeButton, bar, indicator, label, styleLink, intervals, timeouts, window,
|
||||
captures, recorders, analysers, assistantRows, toasts, reducedMotion,
|
||||
get sent() { return sent; },
|
||||
get lastAudio() { return lastAudio; },
|
||||
get uploadedFile() { return uploadedFile; },
|
||||
set now(value) { now = value; },
|
||||
rejectCapture() { rejectNextCapture = true; },
|
||||
resolveTranscript(payload) {
|
||||
assert.ok(transcriptResolve, 'transcription request was not started');
|
||||
transcriptResolve({ok: true, json: async () => payload});
|
||||
},
|
||||
runIntervals() { for (const callback of [...intervals.values()]) callback(); },
|
||||
runTimeout(delay) {
|
||||
const match = [...timeouts].find(([, timer]) => timer.delay === delay);
|
||||
assert.ok(match, `timer ${delay}ms was not scheduled`);
|
||||
timeouts.delete(match[0]);
|
||||
match[1].callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function normalMotionContract(scriptPath, mediaFixture) {
|
||||
const probe = await boot(scriptPath, false);
|
||||
const originalStyleLink = probe.styleLink;
|
||||
|
||||
probe.modeButton.click();
|
||||
assert.match(probe.indicator.className, /\blistening\b/);
|
||||
assert.equal(probe.label.textContent, 'Listening');
|
||||
assert.equal(probe.bar.style.display, '');
|
||||
await flush();
|
||||
assert.equal(probe.captures.length, 1);
|
||||
|
||||
const mediaChunks = mediaFixture.chunks;
|
||||
const preSpeechCount = mediaFixture.pre_speech_chunk_count;
|
||||
for (const chunk of mediaChunks.slice(0, preSpeechCount)) {
|
||||
probe.recorders[0].ondataavailable({
|
||||
data: new Blob([chunk], {type: mediaFixture.mime_type}),
|
||||
});
|
||||
}
|
||||
|
||||
probe.analysers[0].level = 0.3;
|
||||
probe.runIntervals();
|
||||
probe.runIntervals();
|
||||
probe.runIntervals();
|
||||
for (const chunk of mediaChunks.slice(preSpeechCount)) {
|
||||
probe.recorders[0].ondataavailable({
|
||||
data: new Blob([chunk], {type: mediaFixture.mime_type}),
|
||||
});
|
||||
}
|
||||
probe.now = 4000;
|
||||
probe.analysers[0].level = 0;
|
||||
probe.runIntervals();
|
||||
await flush();
|
||||
await flush();
|
||||
assert.match(probe.indicator.className, /\btranscribing\b/);
|
||||
assert.equal(probe.label.textContent, 'Transcribing…');
|
||||
assert.ok(probe.uploadedFile, 'transcription upload was not captured');
|
||||
assert.equal(probe.uploadedFile.name, 'voice-input.webm');
|
||||
assert.equal(probe.uploadedFile.type, mediaFixture.mime_type);
|
||||
const expectedUpload = Buffer.concat([
|
||||
mediaChunks[0],
|
||||
...mediaChunks.slice(preSpeechCount - 3),
|
||||
]);
|
||||
assert.deepEqual(Buffer.from(probe.uploadedFile.bytes), expectedUpload);
|
||||
|
||||
probe.resolveTranscript({transcript: 'Hello Hermes'});
|
||||
await flush();
|
||||
await flush();
|
||||
assert.match(probe.indicator.className, /\bthinking\b/);
|
||||
assert.equal(probe.label.textContent, 'Thinking…');
|
||||
assert.equal(probe.sent, 1);
|
||||
|
||||
probe.assistantRows.push({dataset: {rawText: 'A calm answer.'}});
|
||||
const capturesBeforeSpeech = probe.captures.length;
|
||||
probe.window.autoReadLastAssistant();
|
||||
assert.match(probe.indicator.className, /\bspeaking\b/);
|
||||
assert.equal(probe.label.textContent, 'Speaking');
|
||||
assert.equal(probe.captures.length, capturesBeforeSpeech);
|
||||
await flush();
|
||||
await flush();
|
||||
assert.match(probe.indicator.className, /\bis-playing\b/);
|
||||
assert.equal(probe.captures.length, capturesBeforeSpeech);
|
||||
probe.lastAudio.finish();
|
||||
await flush();
|
||||
assert.doesNotMatch(probe.indicator.className, /\bis-playing\b/);
|
||||
|
||||
return {probe, originalStyleLink, capturesBeforeSpeech};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = process.argv[2];
|
||||
const fixturePath = process.argv[3];
|
||||
assert.ok(
|
||||
scriptPath && fixturePath,
|
||||
'usage: node hermes_voice_instrument_probe.js <atlas-voice.js> <media-fixture.json>',
|
||||
);
|
||||
const mediaFixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const payload = fs.readFileSync(path.join(path.dirname(fixturePath), mediaFixture.payload_file));
|
||||
let offset = 0;
|
||||
mediaFixture.chunks = mediaFixture.chunk_sizes.map(size => {
|
||||
const chunk = payload.subarray(offset, offset + size);
|
||||
offset += size;
|
||||
return chunk;
|
||||
});
|
||||
assert.equal(offset, payload.length, 'fixture chunk sizes do not cover the payload');
|
||||
const first = await normalMotionContract(scriptPath, mediaFixture);
|
||||
const {probe, originalStyleLink, capturesBeforeSpeech} = first;
|
||||
|
||||
assert.equal(probe.captures.length, capturesBeforeSpeech);
|
||||
assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink);
|
||||
|
||||
probe.modeButton.click();
|
||||
assert.equal(probe.bar.style.display, 'none');
|
||||
assert.match(probe.indicator.className, /\bidle\b/);
|
||||
probe.modeButton.click();
|
||||
await flush();
|
||||
probe.modeButton.click();
|
||||
assert.equal(probe.bar.style.display, 'none');
|
||||
assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink);
|
||||
|
||||
const reduced = await boot(scriptPath, true);
|
||||
reduced.modeButton.click();
|
||||
await flush();
|
||||
const initialScale = reduced.indicator.style.getPropertyValue('--voice-ripple-scale');
|
||||
reduced.analysers[0].level = 0.5;
|
||||
reduced.runIntervals();
|
||||
assert.equal(reduced.indicator.style.getPropertyValue('--voice-ripple-scale'), initialScale);
|
||||
reduced.modeButton.click();
|
||||
|
||||
const unavailable = await boot(scriptPath, false);
|
||||
unavailable.rejectCapture();
|
||||
unavailable.modeButton.click();
|
||||
await flush();
|
||||
await flush();
|
||||
assert.match(unavailable.indicator.className, /\berror\b/);
|
||||
assert.equal(unavailable.label.textContent, 'Microphone unavailable');
|
||||
assert.equal(unavailable.bar.style.display, '');
|
||||
unavailable.runTimeout(3200);
|
||||
assert.equal(unavailable.bar.style.display, 'none');
|
||||
assert.match(unavailable.indicator.className, /\bidle\b/);
|
||||
|
||||
process.stdout.write('voice instrument DOM contract passed\n');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
463
testing/tests/data/atlas_voice_language_probe.js
Normal file
463
testing/tests/data/atlas_voice_language_probe.js
Normal file
@ -0,0 +1,463 @@
|
||||
// Deterministic browser stub that drives dockerfiles/hermes-webui-atlas-voice.js
|
||||
// through complete hands-free turns with no microphone, audio device or GPU.
|
||||
//
|
||||
// The script under test is an IIFE with no exported seams, so the only honest
|
||||
// way to assert what reaches POST /api/tts is to run it against a fake DOM and
|
||||
// fake clock and record the requests it actually makes. Usage:
|
||||
//
|
||||
// node atlas_voice_language_probe.js <path-to-atlas-voice.js>
|
||||
//
|
||||
// It prints one JSON object describing every scenario to stdout.
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
const SCRIPT_PATH = process.argv[2];
|
||||
if (!SCRIPT_PATH) {
|
||||
throw new Error('usage: atlas_voice_language_probe.js <atlas-voice.js>');
|
||||
}
|
||||
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
||||
|
||||
function flush() {
|
||||
// Four macrotask hops drain the promise chains the script builds around
|
||||
// fetch()/json()/blob()/play() without ever waiting on wall-clock time.
|
||||
return new Promise((resolve) => {
|
||||
let hops = 0;
|
||||
(function hop() {
|
||||
hops += 1;
|
||||
if (hops > 12) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setImmediate(hop);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function makeElement(id) {
|
||||
return {
|
||||
id,
|
||||
style: {
|
||||
values: new Map(),
|
||||
setProperty(name, value) { this.values.set(name, String(value)); },
|
||||
removeProperty(name) { this.values.delete(name); },
|
||||
getPropertyValue(name) { return this.values.get(name) || ''; },
|
||||
},
|
||||
dataset: {},
|
||||
attributes: {},
|
||||
value: '',
|
||||
textContent: '',
|
||||
className: '',
|
||||
classList: {
|
||||
entries: new Set(),
|
||||
add(name) { this.entries.add(name); },
|
||||
remove(name) { this.entries.delete(name); },
|
||||
contains(name) { return this.entries.has(name); },
|
||||
},
|
||||
listeners: [],
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this.attributes, name)
|
||||
? this.attributes[name] : null;
|
||||
},
|
||||
addEventListener(type, handler) { this.listeners.push({ type, handler }); },
|
||||
removeEventListener(type, handler) {
|
||||
this.listeners = this.listeners.filter((entry) => entry.handler !== handler);
|
||||
},
|
||||
click() {
|
||||
const event = { preventDefault() {}, stopImmediatePropagation() {} };
|
||||
this.listeners
|
||||
.filter((entry) => entry.type === 'click')
|
||||
.forEach((entry) => entry.handler(event));
|
||||
},
|
||||
querySelector() { return null; },
|
||||
insertBefore() {},
|
||||
appendChild() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const clock = { now: 1000000 };
|
||||
const timeouts = [];
|
||||
const intervals = new Map();
|
||||
let timerId = 1;
|
||||
|
||||
const ttsRequests = [];
|
||||
const transcribeCalls = [];
|
||||
const toasts = [];
|
||||
const sends = [];
|
||||
|
||||
let capability = { ok: true, available: true, provider: 'local_command' };
|
||||
let transcribeResponse = { ok: true, transcript: 'hello', language: 'en' };
|
||||
let transcribeStatus = 200;
|
||||
let assistantRows = [];
|
||||
let loud = false;
|
||||
let recorder = null;
|
||||
|
||||
const storage = new Map();
|
||||
const elements = {};
|
||||
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
|
||||
.forEach((id) => { elements[id] = makeElement(id); });
|
||||
|
||||
function MediaRecorder() {
|
||||
this.state = 'recording';
|
||||
this.ondataavailable = null;
|
||||
this.onstop = null;
|
||||
recorder = this;
|
||||
}
|
||||
MediaRecorder.prototype.start = function start() { this.state = 'recording'; };
|
||||
MediaRecorder.prototype.stop = function stop() {
|
||||
if (this.state === 'inactive') return;
|
||||
this.state = 'inactive';
|
||||
if (this.onstop) this.onstop();
|
||||
};
|
||||
MediaRecorder.isTypeSupported = function isTypeSupported() { return true; };
|
||||
|
||||
function AudioContext() {
|
||||
this.createAnalyser = () => ({
|
||||
fftSize: 2048,
|
||||
getByteTimeDomainData(samples) {
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
samples[i] = loud ? (i % 2 ? 200 : 56) : 128;
|
||||
}
|
||||
},
|
||||
});
|
||||
this.createBiquadFilter = () => ({
|
||||
type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {},
|
||||
});
|
||||
this.createMediaStreamSource = () => ({ connect() {} });
|
||||
this.close = () => {};
|
||||
}
|
||||
|
||||
function AudioElement() {
|
||||
this.currentTime = 0;
|
||||
this.onended = null;
|
||||
this.onerror = null;
|
||||
this.pause = () => {};
|
||||
this.play = () => {
|
||||
setImmediate(() => { if (this.onended) this.onended(); });
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchStub(url, init) {
|
||||
if (url === '/api/transcribe/capability') {
|
||||
return { ok: true, status: 200, json: async () => capability };
|
||||
}
|
||||
if (url === '/api/transcribe') {
|
||||
transcribeCalls.push({ body: init && init.body });
|
||||
return {
|
||||
ok: transcribeStatus < 400,
|
||||
status: transcribeStatus,
|
||||
json: async () => transcribeResponse,
|
||||
};
|
||||
}
|
||||
if (url === '/api/tts') {
|
||||
ttsRequests.push(JSON.parse(init.body));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => ({ synthetic: true }),
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}
|
||||
|
||||
const context = {
|
||||
console,
|
||||
Uint8Array,
|
||||
Promise,
|
||||
Math,
|
||||
JSON,
|
||||
String,
|
||||
Number,
|
||||
Error,
|
||||
parseInt,
|
||||
isNaN,
|
||||
Set,
|
||||
Map,
|
||||
Array,
|
||||
Object,
|
||||
Date: { now: () => clock.now },
|
||||
Blob: function Blob(parts, options) { this.parts = parts; this.type = (options || {}).type || ''; },
|
||||
File: function File(parts, name, options) {
|
||||
this.parts = parts; this.name = name; this.type = (options || {}).type || '';
|
||||
},
|
||||
FormData: function FormData() { this.entries = []; this.append = (k, v) => this.entries.push([k, v]); },
|
||||
URL: { createObjectURL: () => 'blob:atlas-test', revokeObjectURL() {} },
|
||||
Audio: AudioElement,
|
||||
MediaRecorder,
|
||||
AudioContext,
|
||||
fetch: fetchStub,
|
||||
localStorage: {
|
||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||
setItem: (key, value) => { storage.set(key, String(value)); },
|
||||
removeItem: (key) => { storage.delete(key); },
|
||||
},
|
||||
navigator: {
|
||||
mediaDevices: {
|
||||
getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }),
|
||||
getSupportedConstraints: () => ({}),
|
||||
},
|
||||
},
|
||||
document: {
|
||||
getElementById: (id) => elements[id] || null,
|
||||
querySelectorAll: () => assistantRows,
|
||||
createElement: () => ({ value: '', textContent: '' }),
|
||||
},
|
||||
S: { session: { session_id: 'session-1' }, busy: false },
|
||||
setTimeout: (fn, delay) => {
|
||||
const id = timerId; timerId += 1;
|
||||
timeouts.push({ id, fn, at: clock.now + (delay || 0) });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => {
|
||||
const index = timeouts.findIndex((entry) => entry.id === id);
|
||||
if (index >= 0) timeouts.splice(index, 1);
|
||||
},
|
||||
setInterval: (fn, delay) => {
|
||||
const id = timerId; timerId += 1;
|
||||
intervals.set(id, { fn, delay: delay || 0 });
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id) => { intervals.delete(id); },
|
||||
};
|
||||
context.window = context;
|
||||
context.showToast = (message) => { toasts.push(message); };
|
||||
context.send = () => { sends.push(elements.msg.value); };
|
||||
context.autoResize = () => {};
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(SOURCE, context, { filename: 'atlas-voice.js' });
|
||||
|
||||
function runDueTimeouts() {
|
||||
const due = timeouts.filter((entry) => entry.at <= clock.now);
|
||||
due.forEach((entry) => {
|
||||
const index = timeouts.indexOf(entry);
|
||||
if (index >= 0) timeouts.splice(index, 1);
|
||||
entry.fn();
|
||||
});
|
||||
}
|
||||
|
||||
function tick(ms) {
|
||||
clock.now += ms;
|
||||
Array.from(intervals.values()).forEach((entry) => entry.fn());
|
||||
runDueTimeouts();
|
||||
}
|
||||
|
||||
return {
|
||||
context,
|
||||
elements,
|
||||
ttsRequests,
|
||||
transcribeCalls,
|
||||
toasts,
|
||||
sends,
|
||||
clock,
|
||||
recorder: () => recorder,
|
||||
setLoud: (value) => { loud = value; },
|
||||
setCapability: (value) => { capability = value; },
|
||||
setTranscribeResponse: (value, status) => {
|
||||
transcribeResponse = value;
|
||||
transcribeStatus = status === undefined ? 200 : status;
|
||||
},
|
||||
setAssistantReply: (text) => { assistantRows = [{ dataset: { rawText: text } }]; },
|
||||
setSession: (id) => { context.S.session = { session_id: id }; },
|
||||
advance: (ms) => { clock.now += ms; },
|
||||
tick,
|
||||
runDueTimeouts,
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
// Walk one capture window: pre-roll audio, three loud frames so the VAD latches
|
||||
// speech, then silence past the hangover so MediaRecorder.stop() fires.
|
||||
async function captureSpeech(harness) {
|
||||
const active = harness.recorder();
|
||||
if (!active) throw new Error('voice mode never created a recorder');
|
||||
active.ondataavailable({ data: { size: 512 } });
|
||||
harness.setLoud(true);
|
||||
for (let i = 0; i < 4; i += 1) harness.tick(100);
|
||||
active.ondataavailable({ data: { size: 512 } });
|
||||
harness.setLoud(false);
|
||||
harness.advance(2500);
|
||||
harness.tick(100);
|
||||
await harness.flush();
|
||||
}
|
||||
|
||||
async function startVoiceMode(harness) {
|
||||
await harness.flush();
|
||||
harness.elements.btnVoiceMode.click();
|
||||
await harness.flush();
|
||||
}
|
||||
|
||||
// One complete turn: speak, transcribe, let the app "answer", read it back.
|
||||
async function runTurn(harness, { transcript, language, reply }) {
|
||||
const payload = { ok: true, transcript };
|
||||
if (language !== undefined) payload.language = language;
|
||||
harness.setTranscribeResponse(payload);
|
||||
await captureSpeech(harness);
|
||||
harness.setAssistantReply(reply || 'An answer.');
|
||||
harness.context.autoReadLastAssistant();
|
||||
await harness.flush();
|
||||
}
|
||||
|
||||
async function restartListening(harness) {
|
||||
harness.advance(1000);
|
||||
harness.runDueTimeouts();
|
||||
await harness.flush();
|
||||
}
|
||||
|
||||
const scenarios = {};
|
||||
|
||||
scenarios.english_turn_speaks_english = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: 'What is the weather?', language: 'en' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.russian_turn_speaks_russian = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.spanish_turn_speaks_spanish = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: '¿Qué tal?', language: 'es', reply: 'Muy bien.' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.missing_language_falls_back = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: 'Hello there.' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.unsupported_language_falls_back = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: 'Bonjour tout le monde.', language: 'fr' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.hostile_language_values_are_dropped = async () => {
|
||||
const results = [];
|
||||
const hostile = [
|
||||
'ru; rm -rf /',
|
||||
'../../ru_RU-irina-medium',
|
||||
'ru\\u0000',
|
||||
'RUSSIAN',
|
||||
{ language: 'ru' },
|
||||
['ru'],
|
||||
42,
|
||||
null,
|
||||
'r',
|
||||
'ru ru',
|
||||
'x'.repeat(4096),
|
||||
];
|
||||
for (const language of hostile) {
|
||||
const harness = makeHarness();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await startVoiceMode(harness);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await runTurn(harness, { transcript: 'Say something.', language });
|
||||
results.push({ sent: String(language), tts: harness.ttsRequests });
|
||||
}
|
||||
return { results };
|
||||
};
|
||||
|
||||
scenarios.voice_field_is_never_sent = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: 'Как дела?', language: 'ru' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.language_does_not_leak_into_later_turn = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
|
||||
await restartListening(harness);
|
||||
await runTurn(harness, { transcript: 'And in English?', language: undefined });
|
||||
await restartListening(harness);
|
||||
await runTurn(harness, { transcript: '¿Y ahora?', language: 'es' });
|
||||
return { tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.empty_transcript_does_not_arm_a_language = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
harness.setTranscribeResponse({ ok: true, transcript: ' ', language: 'ru' });
|
||||
await captureSpeech(harness);
|
||||
const sendsAfterBlank = harness.sends.slice();
|
||||
// A reply landing while the blank turn winds down must not inherit a
|
||||
// language that transcript never earned.
|
||||
harness.setAssistantReply('A stray answer.');
|
||||
harness.context.autoReadLastAssistant();
|
||||
await harness.flush();
|
||||
await restartListening(harness);
|
||||
await runTurn(harness, { transcript: 'Hello.', language: undefined });
|
||||
return { sendsAfterBlank, tts: harness.ttsRequests, sends: harness.sends };
|
||||
};
|
||||
|
||||
scenarios.session_change_discards_language = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
|
||||
await captureSpeech(harness);
|
||||
harness.setSession('session-2');
|
||||
harness.setAssistantReply('Reply that belongs to another chat.');
|
||||
harness.context.autoReadLastAssistant();
|
||||
await harness.flush();
|
||||
const afterSwitch = harness.ttsRequests.slice();
|
||||
await restartListening(harness);
|
||||
await runTurn(harness, { transcript: 'Hello again.', language: undefined });
|
||||
return { afterSwitch, tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.deactivation_discards_language = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
|
||||
await captureSpeech(harness);
|
||||
harness.elements.btnVoiceMode.click();
|
||||
await harness.flush();
|
||||
harness.setAssistantReply('Late reply after the user left voice mode.');
|
||||
harness.context.autoReadLastAssistant();
|
||||
await harness.flush();
|
||||
const afterDeactivate = harness.ttsRequests.slice();
|
||||
harness.elements.btnVoiceMode.click();
|
||||
await harness.flush();
|
||||
await runTurn(harness, { transcript: 'Fresh start.', language: undefined });
|
||||
return { afterDeactivate, tts: harness.ttsRequests };
|
||||
};
|
||||
|
||||
scenarios.transcribe_error_speaks_nothing = async () => {
|
||||
const harness = makeHarness();
|
||||
await startVoiceMode(harness);
|
||||
harness.setTranscribeResponse({ error: 'Whisper is down' }, 503);
|
||||
await captureSpeech(harness);
|
||||
harness.setAssistantReply('Some earlier answer.');
|
||||
harness.context.autoReadLastAssistant();
|
||||
await harness.flush();
|
||||
return { tts: harness.ttsRequests, toasts: harness.toasts };
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const output = {};
|
||||
const names = Object.keys(scenarios);
|
||||
for (const name of names) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
output[name] = await scenarios[name]();
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 1));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String((error && error.stack) || error));
|
||||
process.exit(1);
|
||||
});
|
||||
@ -343,7 +343,7 @@ def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||
assert "/api/tts" in voice_script
|
||||
assert "speakResponse(generation)" in voice_script
|
||||
assert "window._splitForTTS(text,280)" in voice_script
|
||||
assert "pending=fetchSpeech(chunks[index+1])" in voice_script
|
||||
assert "pending=fetchSpeech(chunks[index+1],language)" in voice_script
|
||||
assert "restartSoon(token,450)" in voice_script
|
||||
assert "constraints.voiceIsolation=true" in voice_script
|
||||
assert "highpass.frequency.value=140" in voice_script
|
||||
@ -398,13 +398,27 @@ def test_voice_models_are_baked_and_runtime_has_no_public_egress():
|
||||
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
|
||||
assert tts_dockerfile.count("--chmod=0444") == 6
|
||||
assert "ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d" in tts_dockerfile
|
||||
assert tts_dockerfile.count("--chmod=0444") == 12
|
||||
assert "/opt/models/piper/en_US-amy-medium.onnx" in tts_dockerfile
|
||||
assert "/opt/models/piper/ru_RU-irina-medium.onnx" in tts_dockerfile
|
||||
assert "/opt/models/piper/es_MX-claude-high.onnx" in tts_dockerfile
|
||||
assert "chmod 0555 /opt/models /opt/models/piper" in tts_dockerfile
|
||||
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
|
||||
assert "HERMES_TTS_VOICE=en_US-amy-medium" in tts_dockerfile
|
||||
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
|
||||
assert "download_voice" not in tts_server
|
||||
assert "baked Piper voice is missing" in tts_server
|
||||
assert "session_options.intra_op_num_threads = ONNX_THREADS" in tts_server
|
||||
assert "session_options.intra_op_num_threads = threads" in tts_server
|
||||
assert 'LANGUAGE_VOICE_MAP = {' in tts_server
|
||||
assert '"en": "en_US-amy-medium"' in tts_server
|
||||
assert '"ru": "ru_RU-irina-medium"' in tts_server
|
||||
assert '"es": "es_MX-claude-high"' in tts_server
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
voice_policy = next(
|
||||
|
||||
@ -11,9 +11,70 @@ import pytest
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
HERMES,
|
||||
_documents,
|
||||
)
|
||||
|
||||
|
||||
def _containers(path: Path) -> list[dict]:
|
||||
"""Return every container and init container in one workload document."""
|
||||
spec = _documents(path)[0]["spec"]["template"]["spec"]
|
||||
return [*spec.get("initContainers", []), *spec.get("containers", [])]
|
||||
|
||||
|
||||
def _env(container: dict) -> dict[str, str]:
|
||||
return {
|
||||
entry["name"]: entry.get("value", "")
|
||||
for entry in container.get("env", [])
|
||||
if isinstance(entry, dict) and "name" in entry
|
||||
}
|
||||
|
||||
|
||||
def test_chat_continuity_polls_the_route_its_own_backend_serves():
|
||||
"""The injected fallback must speak the tenant WebUI session contract."""
|
||||
fallback = (HERMES / "router" / "session_continuity.go").read_text(encoding="utf-8")
|
||||
snapshot = (HERMES / "router" / "session_snapshot.go").read_text(encoding="utf-8")
|
||||
script = fallback.split("const sessionContinuityJS = `", 1)[1].rsplit("`", 1)[0]
|
||||
|
||||
assert "'/api/session?session_id=' + encodeURIComponent(sessionId)" in script
|
||||
assert "&messages=1&msg_limit=24" in script
|
||||
assert "hermes_fallback=1" in script
|
||||
# A 409 is the only answer that means "not in this account's active scope".
|
||||
assert "session_profile_mismatch" in script
|
||||
assert 'sessionFallbackPath = "/api/session"' in fallback
|
||||
assert "request.URL.Path != sessionFallbackPath" in snapshot
|
||||
|
||||
# /api/sessions/<id>[/messages] is the Hermes agent dashboard contract. The
|
||||
# chat tenants never route it, so polling it was a permanent 404 that
|
||||
# reported a false ownership failure after every full page load.
|
||||
assert "/api/sessions/" not in script
|
||||
|
||||
|
||||
def test_dashboard_session_route_never_backs_the_chat_tenants():
|
||||
"""Only the agent dashboard gains /api/sessions/{id}/messages."""
|
||||
activity_patch = "patch_web_session_activity.py"
|
||||
marker = '@app.get("/api/sessions/{session_id}/messages")'
|
||||
assert marker in (HERMES / "scripts" / activity_patch).read_text(encoding="utf-8")
|
||||
|
||||
agent = _containers(HERMES / "agent-deployment.yaml")
|
||||
assert any(
|
||||
activity_patch in " ".join(map(str, container.get("command", []) + container.get("args", [])))
|
||||
for container in agent
|
||||
)
|
||||
|
||||
tenants = _containers(HERMES / "chat-statefulset.yaml")
|
||||
assert not any(
|
||||
activity_patch in " ".join(map(str, container.get("command", []) + container.get("args", [])))
|
||||
for container in tenants
|
||||
)
|
||||
|
||||
# The router proxies browser traffic to this WebUI container, and asserts
|
||||
# the tenant identity through the header the WebUI is told to trust.
|
||||
webui = next(container for container in tenants if container["name"] == "webui")
|
||||
router = (HERMES / "router" / "main.go").read_text(encoding="utf-8")
|
||||
header = _env(webui)["HERMES_WEBUI_TRUSTED_AUTH_HEADER"]
|
||||
assert f'trustedTenantHeader = "{header}"' in router
|
||||
|
||||
|
||||
def test_codex_native_health_overrides_historical_router_errors(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
|
||||
@ -53,6 +53,15 @@ def test_goal_card_continues_after_local_judge_rejects_progress(
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"PROVIDER_HEALTH_PATHS",
|
||||
{
|
||||
"codex": tmp_path / "provider-health/codex.json",
|
||||
"claude": tmp_path / "provider-health/claude.json",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(lanes, "fetch_quota_snapshot", lambda *_a, **_k: {})
|
||||
lanes.atomic_json(
|
||||
lanes.state_path("cassandra", "t_goal"),
|
||||
{"goal_rejections": ["prior incomplete report"]},
|
||||
@ -185,6 +194,15 @@ def test_capacity_fallback_preserves_first_claude_structured_response(
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"PROVIDER_HEALTH_PATHS",
|
||||
{
|
||||
"codex": tmp_path / "provider-health/codex.json",
|
||||
"claude": tmp_path / "provider-health/claude.json",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(lanes, "fetch_quota_snapshot", lambda *_a, **_k: {})
|
||||
claude = lanes.Route(
|
||||
"claude", "claude-fable-5", "high", "claude-high", "test", "test", 1, ()
|
||||
)
|
||||
@ -194,8 +212,8 @@ def test_capacity_fallback_preserves_first_claude_structured_response(
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda _prompt, assignee, **_kwargs: codex
|
||||
if assignee == "cli-codex-high"
|
||||
lambda _prompt, assignee, **kwargs: codex
|
||||
if kwargs.get("exclude_provider") == "claude"
|
||||
else claude,
|
||||
)
|
||||
reports = [
|
||||
|
||||
@ -157,13 +157,18 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
|
||||
|
||||
def select_route(_prompt, lane, **kwargs):
|
||||
route_calls.append((lane, kwargs))
|
||||
if lane == "cli-auto":
|
||||
assert lane == "cli-auto"
|
||||
if not kwargs.get("exclude_provider"):
|
||||
return lanes.Route(
|
||||
"codex", "gpt-5.6-terra", "xhigh", "codex-xhigh",
|
||||
"switchyard-classifier", "vote", 1, (),
|
||||
)
|
||||
assert lane == "cli-claude-xhigh"
|
||||
return _route("claude", "xhigh")
|
||||
# Automatic reclassification at the retry boundary: this must remain
|
||||
# a Jetson classifier decision, not a hardcoded manual alternate.
|
||||
return lanes.Route(
|
||||
"claude", "claude-opus-5", "xhigh", "claude-xhigh",
|
||||
"switchyard-classifier", "vote", 1, (),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||
fallbacks: list = []
|
||||
@ -184,6 +189,17 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
|
||||
|
||||
assert reports == []
|
||||
assert calls and calls[0][0] == "complete"
|
||||
# Exactly two Switchyard selections: the initial classification and the
|
||||
# automatic retry with an explicit failed-provider exclusion. No third
|
||||
# (manual pin) call, because the classifier already preserved effort.
|
||||
assert len(route_calls) == 2
|
||||
initial_lane, initial_kwargs = route_calls[0]
|
||||
assert initial_lane == "cli-auto"
|
||||
assert not initial_kwargs.get("exclude_provider")
|
||||
retry_lane, retry_kwargs = route_calls[1]
|
||||
assert retry_lane == "cli-auto"
|
||||
assert retry_kwargs["exclude_provider"] == "codex"
|
||||
assert "quota" in retry_kwargs["exclude_reason"]
|
||||
assert fallbacks == [("codex", "claude", "quota")]
|
||||
assert any(
|
||||
"Provider fallback: codex -> claude after a quota failure" in item
|
||||
@ -196,6 +212,81 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
|
||||
assert claude_health["state"] == "available"
|
||||
|
||||
|
||||
def test_auto_failover_escalates_when_classifier_downgrades_effort(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_auto_escalate",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=39,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=120,
|
||||
)
|
||||
comments: list = []
|
||||
calls: list = []
|
||||
board = _lane_board(tmp_path, task, comments, calls)
|
||||
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
|
||||
route_calls: list = []
|
||||
|
||||
def select_route(_prompt, lane, **kwargs):
|
||||
route_calls.append((lane, kwargs))
|
||||
if lane == "cli-auto" and not kwargs.get("exclude_provider"):
|
||||
return lanes.Route(
|
||||
"codex", "gpt-5.6-terra", "high", "codex-high",
|
||||
"switchyard-classifier", "vote", 1, (),
|
||||
)
|
||||
if lane == "cli-auto" and kwargs.get("exclude_provider") == "codex":
|
||||
# Jetson reclassifies but picks a lower effort than the original
|
||||
# route; the lane must never let capacity failover downgrade it.
|
||||
return lanes.Route(
|
||||
"claude", "claude-haiku-4-5", "low", "claude-low",
|
||||
"switchyard-classifier", "vote", 1, (),
|
||||
)
|
||||
assert lane == "cli-claude-high"
|
||||
return _route("claude", "high")
|
||||
|
||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||
fallbacks: list = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"record_provider_fallback",
|
||||
lambda source, target, reason: fallbacks.append((source, target, reason)),
|
||||
)
|
||||
reports = [
|
||||
lanes.ProcessResult(1, "You have hit your usage limit.", None, True),
|
||||
lanes.ProcessResult(0, "done", dict(COMPLETED_RESULT), False),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
lanes, "run_provider", lambda *_args, **_kwargs: reports.pop(0)
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_auto_escalate")
|
||||
|
||||
assert reports == []
|
||||
assert calls and calls[0][0] == "complete"
|
||||
assert [lane for lane, _ in route_calls] == [
|
||||
"cli-auto",
|
||||
"cli-auto",
|
||||
"cli-claude-high",
|
||||
]
|
||||
# The provider fallback is recorded against the final, effort-preserved
|
||||
# selection, not the transient low-effort classification.
|
||||
assert fallbacks == [("codex", "claude", "quota")]
|
||||
assert any(
|
||||
"escalated to the original high" in item
|
||||
for item in comments
|
||||
)
|
||||
assert any(
|
||||
"Provider fallback: codex -> claude after a quota failure" in item
|
||||
for item in comments
|
||||
)
|
||||
codex_health = json.loads(health_paths["codex"].read_text())
|
||||
assert codex_health["state"] == "capacity-limited"
|
||||
claude_health = json.loads(health_paths["claude"].read_text())
|
||||
assert claude_health["state"] == "available"
|
||||
|
||||
|
||||
def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
@ -212,9 +303,12 @@ def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown(
|
||||
board = _lane_board(tmp_path, task, comments, calls)
|
||||
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
|
||||
|
||||
def select_route(_prompt, lane, **_kwargs):
|
||||
def select_route(_prompt, lane, **kwargs):
|
||||
assert lane == "cli-auto"
|
||||
return (
|
||||
_route("codex", "high") if lane == "cli-auto" else _route("claude", "high")
|
||||
_route("claude", "high")
|
||||
if kwargs.get("exclude_provider") == "codex"
|
||||
else _route("codex", "high")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||
@ -286,9 +380,12 @@ def test_double_capacity_failure_blocks_transient_with_both_reasons(
|
||||
board = _lane_board(tmp_path, task, comments, calls)
|
||||
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
|
||||
|
||||
def select_route(_prompt, lane, **_kwargs):
|
||||
def select_route(_prompt, lane, **kwargs):
|
||||
assert lane == "cli-auto"
|
||||
return (
|
||||
_route("codex", "high") if lane == "cli-auto" else _route("claude", "high")
|
||||
_route("claude", "high")
|
||||
if kwargs.get("exclude_provider") == "codex"
|
||||
else _route("codex", "high")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||
|
||||
@ -182,6 +182,10 @@ class _Lane:
|
||||
|
||||
def _select_route(self, _prompt, assignee, **kwargs):
|
||||
self.routes.append((assignee, kwargs))
|
||||
if assignee == "cli-auto":
|
||||
if kwargs.get("exclude_provider") == "claude":
|
||||
return self._route("codex")
|
||||
return self._route("claude")
|
||||
if assignee.startswith("cli-codex"):
|
||||
return self._route("codex")
|
||||
return self._route("claude")
|
||||
|
||||
@ -74,9 +74,9 @@ def test_router_outage_during_fallback_selection_blocks_transient(
|
||||
_isolate_lane(tmp_path, monkeypatch, board)
|
||||
selections: list = []
|
||||
|
||||
def select_route(_prompt, lane, **_kwargs):
|
||||
def select_route(_prompt, lane, **kwargs):
|
||||
selections.append(lane)
|
||||
if lane == "cli-auto":
|
||||
if lane == "cli-auto" and not kwargs.get("exclude_provider"):
|
||||
return _route("codex", "medium")
|
||||
raise RuntimeError("Switchyard worker routing failed: refused")
|
||||
|
||||
@ -89,7 +89,9 @@ def test_router_outage_during_fallback_selection_blocks_transient(
|
||||
|
||||
lanes.execute_claim("cassandra", "t_router_fb")
|
||||
|
||||
assert selections == ["cli-auto", "cli-claude-medium"]
|
||||
# The automatic retry re-classifies via Switchyard (same "cli-auto" lane)
|
||||
# with an explicit failed-provider exclusion, not a hardcoded manual lane.
|
||||
assert selections == ["cli-auto", "cli-auto"]
|
||||
kind, kwargs = calls[-1]
|
||||
assert kind == "block" and kwargs["kind"] == "transient"
|
||||
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
||||
|
||||
@ -124,7 +124,7 @@ def test_additive_patch_replaces_local_lane_without_touching_base_deployment():
|
||||
environment = {item["name"]: item["value"] for item in local["env"]}
|
||||
assert environment == {
|
||||
"HERMES_CLI_LANE_OWNED_WORKSPACES_ONLY": "true",
|
||||
"HERMES_CLI_LANE_CONCURRENCY": "1",
|
||||
"HERMES_CLI_LANE_CONCURRENCY": "2",
|
||||
}
|
||||
assert pool["resources"]["requests"] == {"cpu": "50m", "memory": "128Mi"}
|
||||
access = next(item for item in pool["volumeMounts"] if item["name"] == "runtime-access")
|
||||
|
||||
203
testing/tests/test_hermes_handsfree_stt.py
Normal file
203
testing/tests/test_hermes_handsfree_stt.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""Browser-to-ffmpeg contracts for Hermes hands-free transcription uploads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
|
||||
STT_PATCHER = ROOT / "dockerfiles/hermes-webui-stt-patch.py"
|
||||
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent-9de9c25f"
|
||||
MEDIA_FIXTURE = ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
|
||||
SAFE_CONVERSION_ERROR = (
|
||||
"Audio conversion failed: upload is invalid, incomplete, or uses an "
|
||||
"unsupported codec"
|
||||
)
|
||||
|
||||
|
||||
def _media_chunks() -> tuple[dict[str, object], list[bytes]]:
|
||||
fixture = json.loads(MEDIA_FIXTURE.read_text(encoding="utf-8"))
|
||||
payload = MEDIA_FIXTURE.with_name(str(fixture["payload_file"])).read_bytes()
|
||||
chunks = []
|
||||
offset = 0
|
||||
for size in fixture["chunk_sizes"]:
|
||||
end = offset + int(size)
|
||||
chunks.append(payload[offset:end])
|
||||
offset = end
|
||||
assert offset == len(payload)
|
||||
return fixture, chunks
|
||||
|
||||
|
||||
def _late_speech_upload(*, preserve_header: bool) -> bytes:
|
||||
fixture, chunks = _media_chunks()
|
||||
count = int(fixture["pre_speech_chunk_count"])
|
||||
retained = chunks[count - 3 :]
|
||||
if preserve_header:
|
||||
retained.insert(0, chunks[0])
|
||||
return b"".join(retained)
|
||||
|
||||
|
||||
def _patched_transcription_module(tmp_path: Path):
|
||||
target = tmp_path / "hermes-agent"
|
||||
shutil.copytree(AGENT_FIXTURE, target)
|
||||
env = os.environ.copy()
|
||||
env["HERMES_AGENT_PATCH_ROOT"] = str(target)
|
||||
subprocess.run(
|
||||
[sys.executable, str(STT_PATCHER)],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
module_path = target / "tools/transcription_tools.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"pinned_transcription_tools", module_path
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _convert(module, tmp_path: Path, payload: bytes, suffix: str):
|
||||
source = tmp_path / f"voice-input{suffix}"
|
||||
output_dir = tmp_path / f"converted-{suffix.removeprefix('.')}"
|
||||
output_dir.mkdir()
|
||||
source.write_bytes(payload)
|
||||
return module._prepare_local_audio(str(source), str(output_dir))
|
||||
|
||||
|
||||
def test_fixture_reproduces_the_current_ebml_header_failure(tmp_path: Path):
|
||||
"""The red control is browser data after the old sliding pre-roll dropped chunk 0."""
|
||||
broken = tmp_path / "current-late-speech.webm"
|
||||
broken.write_bytes(_late_speech_upload(preserve_header=False))
|
||||
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-v", "error", "-y", "-i", str(broken), str(tmp_path / "bad.wav")],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "EBML header" in result.stderr
|
||||
|
||||
|
||||
def test_header_preserved_browser_webm_reaches_real_conversion_boundary(
|
||||
tmp_path: Path,
|
||||
):
|
||||
module = _patched_transcription_module(tmp_path)
|
||||
prepared, error = _convert(
|
||||
module,
|
||||
tmp_path,
|
||||
_late_speech_upload(preserve_header=True),
|
||||
".webm",
|
||||
)
|
||||
|
||||
assert error is None
|
||||
assert prepared is not None
|
||||
output = Path(prepared)
|
||||
assert output.is_file()
|
||||
assert output.read_bytes().startswith(b"RIFF")
|
||||
|
||||
|
||||
def test_invalid_browser_upload_returns_bounded_error_without_ffmpeg_spam(
|
||||
tmp_path: Path,
|
||||
):
|
||||
module = _patched_transcription_module(tmp_path)
|
||||
prepared, error = _convert(
|
||||
module,
|
||||
tmp_path,
|
||||
_late_speech_upload(preserve_header=False),
|
||||
".webm",
|
||||
)
|
||||
|
||||
assert prepared is None
|
||||
assert error == SAFE_CONVERSION_ERROR
|
||||
assert len(error) < 128
|
||||
lowered = error.lower()
|
||||
assert "ffmpeg version" not in lowered
|
||||
assert "configuration:" not in lowered
|
||||
assert "/tmp/" not in lowered
|
||||
|
||||
|
||||
def test_local_conversion_accepts_browser_fallback_containers(tmp_path: Path):
|
||||
module = _patched_transcription_module(tmp_path)
|
||||
formats = ((".ogg", "libopus"), (".mp4", "aac"))
|
||||
|
||||
for suffix, codec in formats:
|
||||
source = tmp_path / f"source{suffix}"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=0.4",
|
||||
"-c:a",
|
||||
codec,
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
prepared, error = _convert(module, tmp_path, source.read_bytes(), suffix)
|
||||
assert error is None
|
||||
assert prepared is not None
|
||||
assert Path(prepared).read_bytes().startswith(b"RIFF")
|
||||
|
||||
|
||||
def test_browser_uses_actual_recorder_mime_and_supported_extensions():
|
||||
source = VOICE_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "let initialChunk=null" in source
|
||||
assert "if(initialChunk){chunks.push(initialChunk);initialChunk=null;}" in source
|
||||
assert "let recordedMime=recorder.mimeType||mime||''" in source
|
||||
assert "if(event.data.type) recordedMime=event.data.type" in source
|
||||
assert "audio/mp4;codecs=mp4a.40.2" in source
|
||||
assert "if(normalized.indexOf('ogg')>=0) return 'ogg'" in source
|
||||
assert "if(normalized.indexOf('mp4')>=0) return 'mp4'" in source
|
||||
|
||||
|
||||
def test_stt_patch_is_built_fail_closed_into_the_webui_image(tmp_path: Path):
|
||||
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "COPY dockerfiles/hermes-webui-stt-patch.py" in dockerfile
|
||||
assert "python /tmp/hermes-webui-stt-patch.py" in dockerfile
|
||||
assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
|
||||
|
||||
drifted = tmp_path / "drifted-agent"
|
||||
shutil.copytree(AGENT_FIXTURE, drifted)
|
||||
transcription = drifted / "tools/transcription_tools.py"
|
||||
source = transcription.read_text(encoding="utf-8")
|
||||
transcription.write_text(
|
||||
source.replace("Failed to convert audio for local STT", "upstream drift", 1),
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["HERMES_AGENT_PATCH_ROOT"] = str(drifted)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(STT_PATCHER)],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "patch context changed" in result.stderr
|
||||
153
testing/tests/test_hermes_public_host_continuity.py
Normal file
153
testing/tests/test_hermes_public_host_continuity.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""Public chat/triage hostnames stay served on every layer during a rename.
|
||||
|
||||
PR #34 renamed the chat and triage public hosts in place: the legacy names left
|
||||
the certificate SANs, the Ingress rules, the CoreDNS overrides and the Keycloak
|
||||
ensure script in one change. The legacy hosts began answering 404 with Traefik's
|
||||
default certificate while the renamed hosts could not finish a login, because
|
||||
Keycloak matches ``redirect_uri`` exactly and still held the old callback. Chat
|
||||
and triage were unreachable on every hostname at once.
|
||||
|
||||
These tests pin the contract that makes that outage impossible to reintroduce
|
||||
silently: a public host is either served by all four layers or by none of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).parents[2]
|
||||
HERMES = REPO / "services/hermes"
|
||||
KEYCLOAK = REPO / "services/keycloak"
|
||||
COREDNS = REPO / "infrastructure/core/coredns-custom.yaml"
|
||||
ENSURE_SCRIPT = KEYCLOAK / "scripts/hermes_access_oidc_ensure.sh"
|
||||
|
||||
# Every hostname the chat/triage surfaces must answer on, with the oauth2-proxy
|
||||
# backend that serves it. Both the renamed and the legacy names belong here
|
||||
# until a deliberate retirement change removes a row from this table.
|
||||
PUBLIC_HOSTS = {
|
||||
"chat.bstein.dev": "oauth2-proxy-hermes-chat",
|
||||
"chat.hermes.bstein.dev": "oauth2-proxy-hermes-chat",
|
||||
"triage.bstein.dev": "oauth2-proxy-hermes-triage",
|
||||
"triage.hermes.bstein.dev": "oauth2-proxy-hermes-triage",
|
||||
}
|
||||
|
||||
# The agent surface was deliberately untouched by the rename.
|
||||
AGENT_HOST = "agent.hermes.bstein.dev"
|
||||
|
||||
|
||||
def _docs(path: Path) -> list[dict]:
|
||||
return [doc for doc in yaml.safe_load_all(path.read_text()) if doc]
|
||||
|
||||
|
||||
def _named(path: Path, kind: str, name: str) -> dict:
|
||||
for doc in _docs(path):
|
||||
if doc.get("kind") == kind and doc["metadata"]["name"] == name:
|
||||
return doc
|
||||
raise AssertionError(f"{kind}/{name} missing from {path}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def certificate() -> dict:
|
||||
return _named(HERMES / "agent-certificate.yaml", "Certificate", "hermes-sites-tls")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sites_ingress() -> dict:
|
||||
return _named(HERMES / "agent-ingress.yaml", "Ingress", "hermes-sites")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def coredns_hosts() -> set[str]:
|
||||
block = yaml.safe_load(COREDNS.read_text())["data"]["bstein-dev.server"]
|
||||
return {
|
||||
line.split()[1]
|
||||
for line in block.splitlines()
|
||||
if len(line.split()) == 2 and re.fullmatch(r"[\d.]+", line.split()[0])
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ensure_script() -> str:
|
||||
return ENSURE_SCRIPT.read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
|
||||
def test_host_is_on_the_shared_certificate(host: str, certificate: dict):
|
||||
"""A host without a SAN serves Traefik's default self-signed certificate."""
|
||||
assert host in certificate["spec"]["dnsNames"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
|
||||
def test_host_resolves_inside_the_cluster(host: str, coredns_hosts: set[str]):
|
||||
assert host in coredns_hosts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
|
||||
def test_host_has_an_ingress_rule_and_tls_entry(host: str, sites_ingress: dict):
|
||||
"""A host without a rule answers 404 even though DNS and TLS look healthy."""
|
||||
spec = sites_ingress["spec"]
|
||||
assert host in {name for entry in spec["tls"] for name in entry["hosts"]}
|
||||
|
||||
rule = next((item for item in spec["rules"] if item["host"] == host), None)
|
||||
assert rule is not None, f"no hermes-sites rule serves {host}"
|
||||
|
||||
backends = {
|
||||
path["backend"]["service"]["name"] for path in rule["http"]["paths"]
|
||||
}
|
||||
assert backends == {PUBLIC_HOSTS[host]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
|
||||
def test_host_is_registered_with_keycloak(host: str, ensure_script: str):
|
||||
"""Keycloak matches redirect_uri exactly, so every served host needs one."""
|
||||
assert f"https://{host}" in ensure_script
|
||||
|
||||
|
||||
def test_agent_surface_was_not_touched_by_the_rename(
|
||||
certificate: dict, coredns_hosts: set[str], ensure_script: str
|
||||
):
|
||||
assert AGENT_HOST in certificate["spec"]["dnsNames"]
|
||||
assert AGENT_HOST in coredns_hosts
|
||||
assert f"https://{AGENT_HOST}" in ensure_script
|
||||
|
||||
|
||||
def test_ensure_script_registers_legacy_and_renamed_origins_together(
|
||||
ensure_script: str,
|
||||
):
|
||||
"""The renamed proxies must carry both origins; the agent proxy only one."""
|
||||
for client, canonical, legacy in (
|
||||
("hermes-chat-proxy", "chat.bstein.dev", "chat.hermes.bstein.dev"),
|
||||
("hermes-triage-proxy", "triage.bstein.dev", "triage.hermes.bstein.dev"),
|
||||
):
|
||||
call = re.search(
|
||||
rf'ensure_proxy_client "{client}".*?(?=\nensure_)',
|
||||
ensure_script,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert call, f"{client} is never ensured"
|
||||
assert f"https://{canonical}" in call.group(0)
|
||||
assert f"https://{legacy}" in call.group(0)
|
||||
|
||||
|
||||
def test_ensure_job_is_rerun_whenever_the_script_changes():
|
||||
"""The Job is immutable, so a stale name silently skips the rerun."""
|
||||
job = _named(
|
||||
KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml",
|
||||
"Job",
|
||||
# The suffix moves with every rerun; resolve it from the manifest.
|
||||
_job_name(),
|
||||
)
|
||||
assert job["spec"]["template"]["spec"]["containers"][0]["command"] == [
|
||||
"/scripts/hermes_access_oidc_ensure.sh"
|
||||
]
|
||||
|
||||
|
||||
def _job_name() -> str:
|
||||
path = KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml"
|
||||
name = yaml.safe_load(path.read_text())["metadata"]["name"]
|
||||
assert re.fullmatch(r"hermes-access-oidc-client-ensure-\d+", name), name
|
||||
return name
|
||||
220
testing/tests/test_hermes_tts_language_routing.py
Normal file
220
testing/tests/test_hermes_tts_language_routing.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""Language allow-list contracts for the private Hermes chat TTS voice policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_chat_support import ROOT
|
||||
|
||||
AMY = "en_US-amy-medium"
|
||||
IRINA = "ru_RU-irina-medium"
|
||||
CLAUDE = "es_MX-claude-high"
|
||||
|
||||
|
||||
def _load_tts_server(monkeypatch):
|
||||
server_path = ROOT / "dockerfiles" / "hermes-jetson-tts-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_jetson_tts_server", server_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
|
||||
class _FakeSessionOptions:
|
||||
def __init__(self) -> None:
|
||||
self.intra_op_num_threads = None
|
||||
self.inter_op_num_threads = None
|
||||
|
||||
fake_onnxruntime = SimpleNamespace(
|
||||
SessionOptions=_FakeSessionOptions,
|
||||
InferenceSession=lambda *a, **k: SimpleNamespace(),
|
||||
)
|
||||
fake_piper = SimpleNamespace(
|
||||
PiperConfig=SimpleNamespace(from_dict=lambda d: d),
|
||||
PiperVoice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
SynthesisConfig=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "onnxruntime", fake_onnxruntime)
|
||||
monkeypatch.setitem(sys.modules, "piper", fake_piper)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tts(monkeypatch):
|
||||
return _load_tts_server(monkeypatch)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"language,expected",
|
||||
[
|
||||
("en", AMY),
|
||||
("en-US", AMY),
|
||||
("en_US", AMY),
|
||||
("EN", AMY),
|
||||
("En-Us", AMY),
|
||||
("ru", IRINA),
|
||||
("ru-RU", IRINA),
|
||||
("ru_RU", IRINA),
|
||||
("RU", IRINA),
|
||||
("es", CLAUDE),
|
||||
("es-MX", CLAUDE),
|
||||
("es_MX", CLAUDE),
|
||||
("es-ES", CLAUDE),
|
||||
("es_ES", CLAUDE),
|
||||
("ES", CLAUDE),
|
||||
],
|
||||
)
|
||||
def test_allow_listed_languages_resolve_to_the_approved_voice(tts, language, expected):
|
||||
assert tts.resolve_voice_name(language) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"language",
|
||||
[
|
||||
None,
|
||||
"",
|
||||
" ",
|
||||
"fr",
|
||||
"fr-FR",
|
||||
"de-DE",
|
||||
"xx",
|
||||
"en-GB",
|
||||
"es-AR",
|
||||
"english",
|
||||
123,
|
||||
1.5,
|
||||
True,
|
||||
[],
|
||||
{},
|
||||
{"lang": "ru"},
|
||||
"../../etc/passwd",
|
||||
"en_US-amy-medium/../../ru_RU-irina-medium",
|
||||
"\x00ru",
|
||||
"ru\x00",
|
||||
],
|
||||
)
|
||||
def test_unknown_missing_or_malformed_language_falls_back_to_amy(tts, language):
|
||||
assert tts.resolve_voice_name(language) == AMY
|
||||
|
||||
|
||||
def test_default_voice_name_matches_the_dockerfile_env_default(tts):
|
||||
assert tts.DEFAULT_VOICE_NAME == AMY
|
||||
|
||||
|
||||
def test_resolved_voice_is_always_one_of_the_three_baked_names(tts):
|
||||
assert frozenset({AMY, IRINA, CLAUDE}) == tts.BAKED_VOICE_NAMES
|
||||
fuzz_inputs = [
|
||||
"en", "ru", "es", "unknown", "", None, 42, "../../../etc/shadow",
|
||||
"en_US-amy-medium\x00; rm -rf /", "RU-ru", "Es-Es", "en-us-extra",
|
||||
]
|
||||
for value in fuzz_inputs:
|
||||
assert tts.resolve_voice_name(value) in tts.BAKED_VOICE_NAMES
|
||||
|
||||
|
||||
def test_client_voice_field_cannot_override_the_language_policy(tts):
|
||||
"""The POST handler must select the voice from "language" only.
|
||||
|
||||
A malicious or stale "voice" field in a hostile/legacy request must never
|
||||
change which baked model answers the request.
|
||||
"""
|
||||
calls: list[str] = []
|
||||
|
||||
class _RecordingVoice:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def synthesize_wav(self, text, wav_file, syn_config) -> None:
|
||||
calls.append(self.name)
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(16_000)
|
||||
wav_file.writeframes(b"\x00\x00")
|
||||
|
||||
class _RecordingHandler(tts.SpeechHandler):
|
||||
def __init__(self, payload):
|
||||
request = json.dumps(payload).encode("utf-8")
|
||||
self.path = "/v1/audio/speech"
|
||||
self.headers = {"Content-Length": str(len(request))}
|
||||
self.rfile = io.BytesIO(request)
|
||||
self.wfile = io.BytesIO()
|
||||
self.status = None
|
||||
self.response_headers = {}
|
||||
self.server = SimpleNamespace(
|
||||
voices={
|
||||
AMY: _RecordingVoice(AMY),
|
||||
IRINA: _RecordingVoice(IRINA),
|
||||
CLAUDE: _RecordingVoice(CLAUDE),
|
||||
},
|
||||
default_voice_name=AMY,
|
||||
)
|
||||
|
||||
def send_response(self, status, message=None):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, name, value):
|
||||
self.response_headers[name] = value
|
||||
|
||||
def end_headers(self):
|
||||
return None
|
||||
|
||||
# A payload that supplies an attacker/legacy "voice" value but no
|
||||
# language must resolve to the safe default, never the "voice" value.
|
||||
handler = _RecordingHandler({"input": "hi", "voice": IRINA})
|
||||
handler.do_POST()
|
||||
assert handler.status == 200
|
||||
assert handler.response_headers["X-TTS-Voice"] == AMY
|
||||
|
||||
# A payload supplying both must still be governed by "language" alone.
|
||||
handler = _RecordingHandler({"input": "hi", "voice": CLAUDE, "language": "ru"})
|
||||
handler.do_POST()
|
||||
assert handler.status == 200
|
||||
assert handler.response_headers["X-TTS-Voice"] == IRINA
|
||||
|
||||
assert calls == [AMY, IRINA]
|
||||
|
||||
|
||||
def test_no_client_string_reaches_a_filesystem_path(tts):
|
||||
"""resolve_voice_name must only ever return a fixed, baked literal.
|
||||
|
||||
This is the property that keeps a client from ever causing the server to
|
||||
build a Path out of attacker-controlled text: the return value is always
|
||||
a member of the fixed allow-list, regardless of input shape.
|
||||
"""
|
||||
hostile_inputs = [
|
||||
"../../../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"en_US-amy-medium/../../../etc/passwd",
|
||||
"ru_RU-irina-medium\x00.onnx",
|
||||
"es_MX-claude-high; cat /etc/shadow",
|
||||
"\n\ren",
|
||||
"en" + "/" * 200,
|
||||
" ",
|
||||
]
|
||||
for value in hostile_inputs:
|
||||
result = tts.resolve_voice_name(value)
|
||||
assert result in tts.BAKED_VOICE_NAMES
|
||||
assert "/" not in result
|
||||
assert ".." not in result
|
||||
assert "\x00" not in result
|
||||
|
||||
|
||||
def test_normalize_language_rejects_non_string_input(tts):
|
||||
assert tts.normalize_language(None) is None
|
||||
assert tts.normalize_language(123) is None
|
||||
assert tts.normalize_language([]) is None
|
||||
assert tts.normalize_language("") is None
|
||||
assert tts.normalize_language(" ") is None
|
||||
assert tts.normalize_language("En_US") == "en-us"
|
||||
|
||||
|
||||
def test_default_voice_name_is_one_of_the_baked_voices(tts):
|
||||
assert tts.DEFAULT_VOICE_NAME in tts.BAKED_VOICE_NAMES
|
||||
|
||||
|
||||
def test_load_voices_fails_closed_when_a_baked_model_is_missing(tts, tmp_path):
|
||||
with pytest.raises(RuntimeError, match="baked Piper voice is missing"):
|
||||
tts.load_voices(tmp_path, threads=1)
|
||||
162
testing/tests/test_hermes_voice_instrument.py
Normal file
162
testing/tests/test_hermes_voice_instrument.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Shipped patch and DOM contracts for the hands-free conversation instrument."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
|
||||
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent"
|
||||
PATCHER = ROOT / "dockerfiles/hermes-webui-atlas-patch.py"
|
||||
VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
|
||||
VOICE_CSS = ROOT / "dockerfiles/hermes-webui-atlas-voice.css"
|
||||
DOM_PROBE = ROOT / "testing/probes/hermes_voice_instrument_probe.js"
|
||||
MEDIARECORDER_FIXTURE = (
|
||||
ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
|
||||
)
|
||||
|
||||
|
||||
def _patched_fixture(tmp_path: Path) -> Path:
|
||||
target = tmp_path / "hermes-webui"
|
||||
agent_target = tmp_path / "hermes-agent"
|
||||
shutil.copytree(FIXTURE, target)
|
||||
shutil.copytree(AGENT_FIXTURE, agent_target)
|
||||
env = os.environ.copy()
|
||||
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
||||
env["HERMES_AGENT_PATCH_ROOT"] = str(agent_target)
|
||||
subprocess.run(
|
||||
[sys.executable, str(PATCHER)],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def test_real_upstream_fixture_receives_visual_instrument_contract(tmp_path: Path):
|
||||
"""Apply the production patcher to exact fragments from pinned WebUI 0.52.181."""
|
||||
target = _patched_fixture(tmp_path)
|
||||
index = (target / "static/index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert index.count('id="voiceInstrumentStyles"') == 1
|
||||
assert (
|
||||
'href="static/atlas-voice.css?v=__WEBUI_VERSION__"' in index
|
||||
)
|
||||
assert 'role="status"' in index
|
||||
assert 'aria-live="polite"' in index
|
||||
assert 'aria-atomic="true"' in index
|
||||
for layer in (
|
||||
"voice-instrument-halo",
|
||||
"voice-instrument-ripple",
|
||||
"voice-instrument-orbit",
|
||||
"voice-instrument-core",
|
||||
"voice-instrument-symbol",
|
||||
):
|
||||
assert layer in index
|
||||
assert '<button' not in index[index.index('id="voiceModeBar"') : index.index('<textarea')]
|
||||
|
||||
|
||||
def test_patched_webui_has_no_user_voice_choice_or_client_voice_field(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""The pinned settings DOM and every TTS path leave speakers to policy."""
|
||||
target = _patched_fixture(tmp_path)
|
||||
index = (target / "static/index.html").read_text(encoding="utf-8")
|
||||
ui = (target / "static/ui.js").read_text(encoding="utf-8")
|
||||
panels = (target / "static/panels.js").read_text(encoding="utf-8")
|
||||
boot = (target / "static/boot.js").read_text(encoding="utf-8")
|
||||
i18n = (target / "static/i18n.js").read_text(encoding="utf-8")
|
||||
config = (target / "api/config.py").read_text(encoding="utf-8")
|
||||
routes = (target / "api/routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "settingsTtsVoice" not in index
|
||||
assert "settings_label_tts_voice" not in index
|
||||
assert "settings_desc_tts_voice" not in index
|
||||
assert "Default system voice" not in index
|
||||
assert 'id="settingsTtsEngine"' in index
|
||||
assert 'id="btnVoiceMode"' in index
|
||||
assert 'id="voiceModeBar"' in index
|
||||
|
||||
assert "hermes-tts-voice" not in ui
|
||||
assert "voice:voice" not in ui
|
||||
assert (
|
||||
"body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, "
|
||||
"engine:engineOverride||'edge'})"
|
||||
) in ui
|
||||
assert "settingsTtsVoice" not in panels
|
||||
assert "tts_voice" not in panels
|
||||
assert "localStorage.removeItem('hermes-tts-voice')" in panels
|
||||
assert panels.count("hermes-tts-voice") == 1
|
||||
assert "hermes-tts-voice" not in boot
|
||||
assert "tts_voice" not in boot
|
||||
assert "text: clean, voice" not in boot
|
||||
assert '"tts_voice"' not in config
|
||||
assert "settings_label_tts_voice" not in i18n
|
||||
assert "settings_desc_tts_voice" not in i18n
|
||||
|
||||
atlas_route = routes.split('if engine == "atlas":', 1)[1].split(
|
||||
"# ── ElevenLabs TTS", 1
|
||||
)[0]
|
||||
assert '"input": text' in atlas_route
|
||||
assert '"voice"' not in atlas_route
|
||||
assert 'request_payload["language"] = _atlas_language' in atlas_route
|
||||
|
||||
|
||||
def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion():
|
||||
css = VOICE_CSS.read_text(encoding="utf-8")
|
||||
|
||||
for state in ("listening", "transcribing", "thinking", "speaking", "error"):
|
||||
assert f".voice-mode-indicator.{state}" in css
|
||||
for animation in (
|
||||
"voice-instrument-breathe",
|
||||
"voice-instrument-orbit",
|
||||
"voice-instrument-speaking-pulse",
|
||||
):
|
||||
assert f"@keyframes {animation}" in css
|
||||
assert (
|
||||
".voice-mode-indicator.speaking.is-playing .voice-instrument-halo"
|
||||
in css
|
||||
)
|
||||
|
||||
error_rules = "\n".join(
|
||||
match.group(0)
|
||||
for match in re.finditer(r"[^{}]*\.error[^{}]*\{[^{}]*\}", css)
|
||||
)
|
||||
assert "animation:" not in error_rules
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
||||
assert "animation: none !important" in reduced
|
||||
assert "transition: none !important" in reduced
|
||||
|
||||
|
||||
def test_dom_probe_exercises_actual_injected_voice_script():
|
||||
result = subprocess.run(
|
||||
["node", str(DOM_PROBE), str(VOICE_JS), str(MEDIARECORDER_FIXTURE)],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.stdout.strip() == "voice instrument DOM contract passed"
|
||||
|
||||
|
||||
def test_visual_slice_preserves_private_voice_request_and_capture_contract():
|
||||
script = VOICE_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert script.count("navigator.mediaDevices.getUserMedia(") == 1
|
||||
assert "form.append('file',new File([blob],'voice-input.'+ext" in script
|
||||
assert "fetch('/api/transcribe',{method:'POST',body:form})" in script
|
||||
assert "const request={text:chunk,engine:'atlas'}" in script
|
||||
assert "if(language) request.language=language" in script
|
||||
assert "speakResponse(generation)" in script
|
||||
assert "window._voiceModeImmediateSend" in script
|
||||
assert "mute" not in script.lower()
|
||||
assert "const TTS_LANGUAGES=['en','ru','es']" in script
|
||||
677
testing/tests/test_hermes_voice_language_routing.py
Normal file
677
testing/tests/test_hermes_voice_language_routing.py
Normal file
@ -0,0 +1,677 @@
|
||||
"""Voice-mode language routing: private Whisper STT decides the Piper voice.
|
||||
|
||||
Every assertion here runs without a GPU, a microphone or a cluster. The browser
|
||||
contract is exercised by driving the real ``atlas-voice.js`` inside a stub DOM
|
||||
(``testing/tests/data/atlas_voice_language_probe.js``), and the two server-side
|
||||
trust boundaries are exercised by applying the real image patch to fixtures that
|
||||
carry the exact upstream anchors and then importing the patched result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_chat_support import HERMES, ROOT
|
||||
|
||||
DOCKERFILES = ROOT / "dockerfiles"
|
||||
ATLAS_PATCH = DOCKERFILES / "hermes-webui-atlas-patch.py"
|
||||
VOICE_SCRIPT = DOCKERFILES / "hermes-webui-atlas-voice.js"
|
||||
VOICE_PROBE = ROOT / "testing" / "tests" / "data" / "atlas_voice_language_probe.js"
|
||||
WEBUI_FIXTURE = ROOT / "testing" / "fixtures" / "hermes-webui-0.52.181"
|
||||
ATLAS_TTS_URL = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
|
||||
|
||||
# Voices baked by the multilingual Piper work (PR #26): en=amy, ru=irina,
|
||||
# es=claude. Anything outside this set must resolve to English.
|
||||
SUPPORTED = ("en", "ru", "es")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module loaders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_stt_server(monkeypatch):
|
||||
"""Import the Jetson Whisper service without CUDA, torch or whisper."""
|
||||
path = DOCKERFILES / "hermes-jetson-stt-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_jetson_stt_server", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_stt_client():
|
||||
"""Import the local-command STT client that Hermes shells out to."""
|
||||
path = HERMES / "scripts" / "hermes_stt_client.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_stt_client", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch fixtures — each file carries the exact upstream fragment the image
|
||||
# patch pins, so importing the patched result exercises the inserted code.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
INDEX_FIXTURE = (
|
||||
'<select id="settingsTtsEngine">'
|
||||
'<option value="browser">Browser speech synthesis</option>'
|
||||
'<option value="edge">Edge TTS (server)</option></select>\n'
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n'
|
||||
)
|
||||
|
||||
UI_FIXTURE = """function _playEdgeTtsChunked(text, btn){
|
||||
fetch('/api/tts',{method:'POST',body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
|
||||
}
|
||||
function readAloud(clean, btn, engine){
|
||||
if(engine==='edge'){
|
||||
_playEdgeTtsChunked(clean, btn);
|
||||
}
|
||||
}
|
||||
function autoRead(clean, engine){
|
||||
if(engine==='edge'){
|
||||
_playEdgeTtsChunked(clean, null);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
HELPERS_FIXTURE = '''"""Stand-in for the WebUI helper module the patched code imports."""
|
||||
|
||||
|
||||
def bad(handler, message, status=400):
|
||||
return {"status": status, "error": message}
|
||||
'''
|
||||
|
||||
ROUTES_FIXTURE = '''"""Stand-in carrying the exact upstream anchors the Atlas TTS patch pins."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from urllib.request import ProxyHandler, Request, build_opener
|
||||
|
||||
|
||||
class _NoRedirectTtsHandler:
|
||||
"""Placeholder for the upstream no-redirect opener handler."""
|
||||
|
||||
|
||||
class _Logger:
|
||||
def __init__(self):
|
||||
self.failures = []
|
||||
|
||||
def exception(self, message):
|
||||
self.failures.append(message)
|
||||
|
||||
|
||||
logger = _Logger()
|
||||
UPSTREAM_REQUESTS = []
|
||||
|
||||
|
||||
class _Upstream:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
|
||||
def _buffer_tts_audio_response(response):
|
||||
return response.read()
|
||||
|
||||
|
||||
def _tts_open(req, *, timeout=30, opener_factory=None):
|
||||
"""Thin network seam for the TTS upstream fetch so tests can intercept it."""
|
||||
UPSTREAM_REQUESTS.append(json.loads(req.data.decode("utf-8")))
|
||||
return _Upstream(b"RIFFsynthetic")
|
||||
|
||||
|
||||
def _handle_tts(handler, data, text, rate_str, engine):
|
||||
# ── ElevenLabs TTS ──────────────────────────────────────────────────
|
||||
return None
|
||||
'''
|
||||
|
||||
UPLOAD_FIXTURE = '''"""Stand-in carrying the exact upstream /api/transcribe response anchor."""
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
return {"status": status, "payload": payload}
|
||||
|
||||
|
||||
def handle_transcribe(handler, result):
|
||||
try:
|
||||
transcript = str(result.get('transcript') or '').strip()
|
||||
return j(handler, {'ok': True, 'transcript': transcript})
|
||||
except ValueError as error:
|
||||
return j(handler, {'error': str(error)}, status=400)
|
||||
'''
|
||||
|
||||
TRANSCRIPTION_FIXTURE = '''"""Stand-in carrying the exact upstream local-command STT envelope anchor."""
|
||||
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _Logger:
|
||||
def info(self, *args):
|
||||
return None
|
||||
|
||||
|
||||
logger = _Logger()
|
||||
|
||||
|
||||
def _transcribe_local_command(file_path, normalized_model, output_dir):
|
||||
try:
|
||||
with contextlib.nullcontext(output_dir):
|
||||
txt_files = sorted(Path(output_dir).glob("*.txt"))
|
||||
transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
||||
logger.info(
|
||||
"Transcribed %s via local STT command (%s, %d chars)",
|
||||
Path(file_path).name,
|
||||
normalized_model,
|
||||
len(transcript_text),
|
||||
)
|
||||
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
|
||||
except OSError as error:
|
||||
return {"success": False, "transcript": "", "error": str(error)}
|
||||
'''
|
||||
|
||||
|
||||
def _write(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_webui(tmp_path, monkeypatch):
|
||||
"""Apply the real Atlas image patch to fixture trees and import the result."""
|
||||
webui = tmp_path / "hermes-webui"
|
||||
agent = tmp_path / "hermes"
|
||||
# Start with the pinned full-surface fixture introduced by PR #39 so this
|
||||
# test proves the language pipeline composes with its voice-selector removal
|
||||
# and conversation instrument, not merely with the older #27 anchors.
|
||||
shutil.copytree(WEBUI_FIXTURE, webui)
|
||||
_write(webui / "api" / "__init__.py", "")
|
||||
_write(webui / "api" / "helpers.py", HELPERS_FIXTURE)
|
||||
_write(webui / "api" / "routes.py", ROUTES_FIXTURE)
|
||||
_write(webui / "api" / "upload.py", UPLOAD_FIXTURE)
|
||||
_write(agent / "tools" / "__init__.py", "")
|
||||
_write(agent / "tools" / "transcription_tools.py", TRANSCRIPTION_FIXTURE)
|
||||
|
||||
environment = dict(os.environ)
|
||||
environment["HERMES_WEBUI_PATCH_ROOT"] = str(webui)
|
||||
environment["HERMES_AGENT_PATCH_ROOT"] = str(agent)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(ATLAS_PATCH)],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr or completed.stdout
|
||||
|
||||
for name in ("api", "api.helpers", "api.routes", "api.upload", "tools",
|
||||
"tools.transcription_tools"):
|
||||
sys.modules.pop(name, None)
|
||||
monkeypatch.syspath_prepend(str(agent))
|
||||
monkeypatch.syspath_prepend(str(webui))
|
||||
import api.routes as routes # noqa: PLC0415
|
||||
import api.upload as upload # noqa: PLC0415
|
||||
import tools.transcription_tools as transcription # noqa: PLC0415
|
||||
|
||||
yield SimpleNamespace(
|
||||
webui=webui,
|
||||
agent=agent,
|
||||
routes=routes,
|
||||
upload=upload,
|
||||
transcription=transcription,
|
||||
)
|
||||
|
||||
for name in ("api", "api.helpers", "api.routes", "api.upload", "tools",
|
||||
"tools.transcription_tools"):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
class _Handler:
|
||||
"""Just enough BaseHTTPRequestHandler surface for the Atlas TTS branch."""
|
||||
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers_sent = {}
|
||||
self.wfile = SimpleNamespace(write=self._write)
|
||||
self.body = b""
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, name, value):
|
||||
self.headers_sent[name] = value
|
||||
|
||||
def end_headers(self):
|
||||
return None
|
||||
|
||||
def _write(self, payload):
|
||||
self.body += payload
|
||||
|
||||
|
||||
def _atlas_tts(patched, monkeypatch, data):
|
||||
"""Run the patched Atlas branch and return the JSON it sent to hermes-tts."""
|
||||
monkeypatch.setenv("HERMES_WEBUI_ATLAS_TTS_URL", ATLAS_TTS_URL)
|
||||
patched.routes.UPSTREAM_REQUESTS.clear()
|
||||
handler = _Handler()
|
||||
result = patched.routes._handle_tts(handler, data, "Some reply.", "", "atlas")
|
||||
assert result is True, "the Atlas branch must own the response"
|
||||
assert handler.status == 200
|
||||
assert len(patched.routes.UPSTREAM_REQUESTS) == 1
|
||||
return patched.routes.UPSTREAM_REQUESTS[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Whisper service reports the language it actually decoded with
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stt_response_carries_whisper_detected_language(monkeypatch):
|
||||
module = _load_stt_server(monkeypatch)
|
||||
payload = module._transcription_payload(
|
||||
{
|
||||
"language": "ru",
|
||||
"segments": [
|
||||
{"text": " Как дела?", "no_speech_prob": 0.1, "avg_logprob": -0.2}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert payload["text"] == "Как дела?"
|
||||
assert payload["language"] == "ru"
|
||||
assert payload["model"] == module.MODEL_NAME
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("en", "en"),
|
||||
("RU", "ru"),
|
||||
(" es ", "es"),
|
||||
("yue", "yue"),
|
||||
("fr", "fr"),
|
||||
("en-US", ""),
|
||||
("en_US", ""),
|
||||
("e", ""),
|
||||
("english", ""),
|
||||
("", ""),
|
||||
("../en", ""),
|
||||
("en\x00", ""),
|
||||
("ru; rm -rf /", ""),
|
||||
("рус", ""),
|
||||
(None, ""),
|
||||
(7, ""),
|
||||
(["ru"], ""),
|
||||
({"language": "ru"}, ""),
|
||||
],
|
||||
)
|
||||
def test_stt_language_field_is_shape_validated(monkeypatch, raw, expected):
|
||||
module = _load_stt_server(monkeypatch)
|
||||
assert module._detected_language({"language": raw}) == expected
|
||||
|
||||
|
||||
def test_stt_language_absent_when_whisper_omits_it(monkeypatch):
|
||||
module = _load_stt_server(monkeypatch)
|
||||
assert module._detected_language({}) == ""
|
||||
assert module._detected_language("not a result") == ""
|
||||
assert module._transcription_payload({"text": "hi"})["language"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The local-command client carries the language without breaking the
|
||||
# .txt contract Hermes reads the transcript from
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stt_client_writes_language_sidecar_beside_the_txt_contract(tmp_path):
|
||||
module = _load_stt_client()
|
||||
module._write_result(tmp_path, "voice-input", "Как дела?", "ru")
|
||||
assert (tmp_path / "voice-input.txt").read_text(encoding="utf-8") == "Как дела?"
|
||||
assert (tmp_path / "voice-input.language").read_text(encoding="utf-8") == "ru"
|
||||
# Hermes globs *.txt and reads the first match: the sidecar must not join it.
|
||||
assert sorted(p.name for p in tmp_path.glob("*.txt")) == ["voice-input.txt"]
|
||||
|
||||
|
||||
def test_stt_client_omits_the_sidecar_when_no_language_was_detected(tmp_path):
|
||||
module = _load_stt_client()
|
||||
module._write_result(tmp_path, "voice-input", "Hello.", "")
|
||||
assert (tmp_path / "voice-input.txt").exists()
|
||||
assert not (tmp_path / "voice-input.language").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("en", "en"),
|
||||
("ES", "es"),
|
||||
(" ru ", "ru"),
|
||||
("en-US", ""),
|
||||
("", ""),
|
||||
("../../etc/passwd", ""),
|
||||
("en\n", "en"),
|
||||
("e", ""),
|
||||
(None, ""),
|
||||
(12, ""),
|
||||
(["en"], ""),
|
||||
],
|
||||
)
|
||||
def test_stt_client_normalises_the_service_language_field(raw, expected):
|
||||
module = _load_stt_client()
|
||||
assert module._normalize_language(raw) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The patched agent envelope and /api/transcribe response carry it through
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patched_local_command_envelope_carries_the_sidecar_language(patched_webui, tmp_path):
|
||||
output = tmp_path / "stt-out"
|
||||
output.mkdir()
|
||||
(output / "voice-input.txt").write_text("Как дела?", encoding="utf-8")
|
||||
(output / "voice-input.language").write_text("ru\n", encoding="utf-8")
|
||||
result = patched_webui.transcription._transcribe_local_command(
|
||||
"/tmp/voice-input.wav", "small", output
|
||||
)
|
||||
assert result == {
|
||||
"success": True,
|
||||
"transcript": "Как дела?",
|
||||
"provider": "local_command",
|
||||
"language": "ru",
|
||||
}
|
||||
|
||||
|
||||
def test_patched_local_command_envelope_defaults_to_no_language(patched_webui, tmp_path):
|
||||
output = tmp_path / "stt-out"
|
||||
output.mkdir()
|
||||
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
|
||||
result = patched_webui.transcription._transcribe_local_command(
|
||||
"/tmp/voice-input.wav", "small", output
|
||||
)
|
||||
assert result["transcript"] == "Hello."
|
||||
assert result["language"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostile",
|
||||
["en-US", "../../en", "en; rm -rf /", "e", "english", "", "\x00en", "e n"],
|
||||
)
|
||||
def test_patched_local_command_envelope_rejects_malformed_sidecars(
|
||||
patched_webui, tmp_path, hostile
|
||||
):
|
||||
output = tmp_path / "stt-out"
|
||||
output.mkdir()
|
||||
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
|
||||
(output / "voice-input.language").write_text(hostile, encoding="utf-8")
|
||||
result = patched_webui.transcription._transcribe_local_command(
|
||||
"/tmp/voice-input.wav", "small", output
|
||||
)
|
||||
assert result["language"] == ""
|
||||
|
||||
|
||||
def test_patched_local_command_envelope_survives_an_undecodable_sidecar(
|
||||
patched_webui, tmp_path
|
||||
):
|
||||
"""A corrupt sidecar must cost the language, never the transcript."""
|
||||
output = tmp_path / "stt-out"
|
||||
output.mkdir()
|
||||
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
|
||||
(output / "voice-input.language").write_bytes(b"\xff\xfe\x00ru")
|
||||
result = patched_webui.transcription._transcribe_local_command(
|
||||
"/tmp/voice-input.wav", "small", output
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["transcript"] == "Hello."
|
||||
assert result["language"] == ""
|
||||
|
||||
|
||||
def test_patched_transcribe_response_reports_the_language(patched_webui):
|
||||
response = patched_webui.upload.handle_transcribe(
|
||||
None, {"success": True, "transcript": " Как дела? ", "language": "ru"}
|
||||
)
|
||||
assert response["payload"] == {"ok": True, "transcript": "Как дела?", "language": "ru"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostile",
|
||||
["", None, "en-US", "englishhh", "../en", 5, ["ru"], {"a": "b"}, "e"],
|
||||
)
|
||||
def test_patched_transcribe_response_blanks_untrusted_languages(patched_webui, hostile):
|
||||
response = patched_webui.upload.handle_transcribe(
|
||||
None, {"success": True, "transcript": "Hello.", "language": hostile}
|
||||
)
|
||||
assert response["payload"]["language"] == ""
|
||||
assert response["payload"]["transcript"] == "Hello."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The /api/tts trust boundary: allow-list only, and never `voice`
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", SUPPORTED)
|
||||
def test_atlas_tts_forwards_allow_listed_languages(patched_webui, monkeypatch, language):
|
||||
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas", "language": language})
|
||||
assert body["language"] == language
|
||||
assert body["model"] == "piper"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostile",
|
||||
[
|
||||
"fr",
|
||||
"de",
|
||||
"",
|
||||
None,
|
||||
"EN-GB",
|
||||
" RU ",
|
||||
"ru-RU",
|
||||
"es_MX",
|
||||
"../../ru_RU-irina-medium",
|
||||
"ru; rm -rf /",
|
||||
"ru\x00",
|
||||
"ру",
|
||||
5,
|
||||
["ru"],
|
||||
{"language": "ru"},
|
||||
True,
|
||||
"x" * 8192,
|
||||
],
|
||||
)
|
||||
def test_atlas_tts_omits_untrusted_languages(
|
||||
patched_webui, monkeypatch, hostile
|
||||
):
|
||||
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas", "language": hostile})
|
||||
assert "language" not in body
|
||||
|
||||
|
||||
def test_atlas_tts_language_is_absent_when_the_client_sends_none(patched_webui, monkeypatch):
|
||||
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas"})
|
||||
assert "language" not in body
|
||||
|
||||
|
||||
def test_atlas_tts_voice_field_cannot_steer_synthesis(patched_webui, monkeypatch):
|
||||
body = _atlas_tts(
|
||||
patched_webui,
|
||||
monkeypatch,
|
||||
{"engine": "atlas", "voice": "ru_RU-irina-medium", "language": "en"},
|
||||
)
|
||||
assert body["language"] == "en"
|
||||
body = _atlas_tts(
|
||||
patched_webui,
|
||||
monkeypatch,
|
||||
{"engine": "atlas", "voice": "es_MX-claude-high"},
|
||||
)
|
||||
assert "language" not in body
|
||||
|
||||
|
||||
def test_atlas_tts_language_helper_only_ever_returns_a_baked_voice_language(patched_webui):
|
||||
resolve = patched_webui.routes._atlas_tts_language
|
||||
hostile = [
|
||||
None, 0, 1, -1, True, False, [], {}, set(), object(), b"ru",
|
||||
"", " ", "\t\n", "en", "EN", "en-US", "en_us", "ru-RU", "es-MX",
|
||||
"e", "eng", "english", "ru ru", "ru;es", "../ru", "ru\x00", "ру",
|
||||
"x" * 65536, "en" * 4096,
|
||||
]
|
||||
for value in hostile:
|
||||
expected = value if value in SUPPORTED else ""
|
||||
assert resolve({"language": value}) == expected
|
||||
for value in hostile:
|
||||
assert resolve(value) == ""
|
||||
assert resolve({"voice": "ru_RU-irina-medium"}) == ""
|
||||
|
||||
|
||||
def test_manual_tts_button_body_still_carries_no_language(patched_webui):
|
||||
"""The read-aloud button has no trusted STT signal, so it must stay Amy."""
|
||||
ui = (patched_webui.webui / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
assert "engine:engineOverride||'edge'" in ui
|
||||
assert "language" not in ui
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Browser contract, driven through the real atlas-voice.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def voice_probe():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node is required to drive the browser voice-mode contract")
|
||||
completed = subprocess.run(
|
||||
[node, str(VOICE_PROBE), str(VOICE_SCRIPT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scenario", "expected"),
|
||||
[
|
||||
("english_turn_speaks_english", "en"),
|
||||
("russian_turn_speaks_russian", "ru"),
|
||||
("spanish_turn_speaks_spanish", "es"),
|
||||
],
|
||||
)
|
||||
def test_voice_mode_speaks_the_language_whisper_detected(voice_probe, scenario, expected):
|
||||
requests = voice_probe[scenario]["tts"]
|
||||
assert requests, "voice mode never reached /api/tts"
|
||||
for request in requests:
|
||||
assert request["engine"] == "atlas"
|
||||
assert request["language"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scenario", ["missing_language_falls_back", "unsupported_language_falls_back"]
|
||||
)
|
||||
def test_voice_mode_omits_language_without_a_trusted_signal(voice_probe, scenario):
|
||||
requests = voice_probe[scenario]["tts"]
|
||||
assert requests, "voice mode never reached /api/tts"
|
||||
for request in requests:
|
||||
assert "language" not in request
|
||||
|
||||
|
||||
def test_voice_mode_drops_hostile_language_values(voice_probe):
|
||||
for case in voice_probe["hostile_language_values_are_dropped"]["results"]:
|
||||
for request in case["tts"]:
|
||||
assert "language" not in request, case["sent"]
|
||||
|
||||
|
||||
def test_voice_mode_never_sends_a_voice_field(voice_probe):
|
||||
for request in voice_probe["voice_field_is_never_sent"]["tts"]:
|
||||
assert set(request) <= {"text", "engine", "language"}
|
||||
assert "voice" not in request
|
||||
|
||||
|
||||
def test_voice_mode_does_not_reuse_a_previous_turn_language(voice_probe):
|
||||
requests = voice_probe["language_does_not_leak_into_later_turn"]["tts"]
|
||||
assert len(requests) == 3
|
||||
assert requests[0]["language"] == "ru"
|
||||
assert "language" not in requests[1]
|
||||
assert requests[2]["language"] == "es"
|
||||
|
||||
|
||||
def test_voice_mode_ignores_language_from_an_empty_transcript(voice_probe):
|
||||
result = voice_probe["empty_transcript_does_not_arm_a_language"]
|
||||
assert result["sendsAfterBlank"] == []
|
||||
assert result["sends"] == ["Hello."]
|
||||
assert result["tts"], "the follow-up turn should still be spoken"
|
||||
for request in result["tts"]:
|
||||
assert "language" not in request
|
||||
|
||||
|
||||
def test_voice_mode_discards_language_when_the_session_changes(voice_probe):
|
||||
result = voice_probe["session_change_discards_language"]
|
||||
assert result["afterSwitch"] == []
|
||||
for request in result["tts"]:
|
||||
assert "language" not in request
|
||||
|
||||
|
||||
def test_voice_mode_discards_language_when_voice_mode_is_turned_off(voice_probe):
|
||||
result = voice_probe["deactivation_discards_language"]
|
||||
assert result["afterDeactivate"] == []
|
||||
for request in result["tts"]:
|
||||
assert "language" not in request
|
||||
|
||||
|
||||
def test_voice_mode_speaks_nothing_when_transcription_fails(voice_probe):
|
||||
result = voice_probe["transcribe_error_speaks_nothing"]
|
||||
assert result["tts"] == []
|
||||
assert any("Whisper is down" in toast for toast in result["toasts"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Build-time enforcement and documented semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_image_build_verifies_every_language_routing_patch():
|
||||
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
||||
assert "'language': detected" in dockerfile
|
||||
assert "def _atlas_tts_language(body):" in dockerfile
|
||||
assert 'request_payload["language"] = _atlas_language' in dockerfile
|
||||
assert '"language": detected_language' in dockerfile
|
||||
assert "takeSttLanguage(token)" in dockerfile
|
||||
assert "/opt/hermes-webui/api/upload.py" in dockerfile
|
||||
assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
|
||||
|
||||
|
||||
def test_atlas_patch_roots_are_overridable_for_offline_verification():
|
||||
patch = ATLAS_PATCH.read_text(encoding="utf-8")
|
||||
assert 'os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")' in patch
|
||||
assert 'os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes")' in patch
|
||||
|
||||
|
||||
def test_notes_document_the_stt_driven_voice_selection_and_its_limits():
|
||||
notes = (HERMES / "NOTES.md").read_text(encoding="utf-8")
|
||||
assert "STT-detected language" in notes
|
||||
for marker in ("hands-free", "Typed messages", "en_US-amy-medium"):
|
||||
assert marker in notes
|
||||
Loading…
x
Reference in New Issue
Block a user