Xiaozhi WebSocket endpoint with handshake/auth, mock STT/TTS/Hermes backends, Thai chunker, barge-in queue, latency logger, GPU planner, voice-profile guard (reasoning=none, session_search only). CON-002: no GPU/audio libs loaded at import. MUST-NOT-001: all model names/tokens from config.
136 lines
4.9 KiB
Python
136 lines
4.9 KiB
Python
"""Hermes voice profile — the read/recall agent (REQ-004/005/008).
|
|
|
|
This is the ONLY bridge to Hermes/Qwen. It enforces the voice contract:
|
|
reasoning **none**, tools = ``session_search`` only, bounded working context,
|
|
multi-turn history. It never writes files, runs shell, or executes code
|
|
(MUST-NOT-003).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from typing import AsyncIterator
|
|
|
|
|
|
@dataclass
|
|
class VoiceTurn:
|
|
text: str
|
|
# Populated when the voice profile performed a session_search call, so the
|
|
# caller can log which session(s) were recalled (REQ-008).
|
|
searched: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Voice profile — enforced, not aspirational (REQ-004 / AC-004)
|
|
# ---------------------------------------------------------------------------
|
|
class VoiceProfile:
|
|
"""Assertions live here so a bad config is caught at startup (AC-004)."""
|
|
|
|
def __init__(self, reasoning: str, tools: list[str]):
|
|
self.reasoning = reasoning
|
|
self.tools = tools
|
|
self._assert()
|
|
|
|
def _assert(self) -> None:
|
|
assert self.reasoning == "none", (
|
|
"Voice profile MUST be reasoning-none (REQ-004)."
|
|
)
|
|
assert "session_search" in self.tools, (
|
|
"Voice profile requires the session_search tool (REQ-008)."
|
|
)
|
|
# No write/exec tools are allowed (MUST-NOT-003).
|
|
forbidden = {"terminal", "shell", "write_file", "edit_file", "code_execution"}
|
|
leaked = forbidden & set(self.tools)
|
|
assert not leaked, f"Voice profile leaked exec/write tools: {leaked}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session — multi-turn, bounded context (REQ-005 / Phase 17)
|
|
# ---------------------------------------------------------------------------
|
|
class VoiceSession:
|
|
def __init__(self, profile: VoiceProfile, max_turns: int = 12):
|
|
self.profile = profile
|
|
self.max_turns = max_turns
|
|
self._history: list[tuple[str, str]] = [] # (user, assistant)
|
|
|
|
def push(self, user: str, assistant: str) -> None:
|
|
self._history.append((user, assistant))
|
|
# Bounded: keep only the most recent N turns (Phase 17).
|
|
if len(self._history) > self.max_turns:
|
|
self._history = self._history[-self.max_turns :]
|
|
|
|
@property
|
|
def history(self) -> list[tuple[str, str]]:
|
|
return list(self._history)
|
|
|
|
|
|
class HermesClient:
|
|
"""Transport to the local Qwen voice profile.
|
|
|
|
``mock`` is deterministic (tests). ``openai_http`` talks to a local
|
|
Hermes/Qwen OpenAI-compatible endpoint (OQ-002 decides the exact surface).
|
|
"""
|
|
|
|
def __init__(self, cfg, profile: VoiceProfile):
|
|
self.cfg = cfg
|
|
self.profile = profile
|
|
self._session: VoiceSession | None = None
|
|
|
|
def session(self) -> VoiceSession:
|
|
if self._session is None:
|
|
self._session = VoiceSession(
|
|
self.profile, self.cfg.max_history_turns
|
|
)
|
|
return self._session
|
|
|
|
async def respond(
|
|
self, user_text: str, *, session_search_fn=None
|
|
) -> AsyncIterator[str]:
|
|
"""Yield the assistant answer as text chunks.
|
|
|
|
``session_search_fn(query)`` is injected by the caller (the session
|
|
layer) so the profile can recall other sessions without this module
|
|
importing the session-search implementation (keeps the transport
|
|
testable). The mock transport never needs it.
|
|
"""
|
|
if self.cfg.transport == "mock":
|
|
answer = f"รับทราบ: {user_text}"
|
|
for ch in _chunks(answer, 8):
|
|
yield ch
|
|
await asyncio.sleep(0)
|
|
return
|
|
|
|
# openai_http — real path (kept minimal; OQ-002 finalizes surface).
|
|
import httpx # lazy
|
|
|
|
msgs = [
|
|
{"role": "system", "content": "You are the user's voice companion."},
|
|
*[
|
|
m for pair in self.session().history for m in (
|
|
{"role": "user", "content": pair[0]},
|
|
{"role": "assistant", "content": pair[1]},
|
|
)
|
|
],
|
|
{"role": "user", "content": user_text},
|
|
]
|
|
async with httpx.AsyncClient() as client:
|
|
async with client.stream(
|
|
"POST",
|
|
f"{self.cfg.base_url}/chat/completions",
|
|
json={
|
|
"messages": msgs,
|
|
"stream": True,
|
|
"reasoning": self.profile.reasoning,
|
|
"tools": self.profile.tools,
|
|
},
|
|
headers={"Authorization": f"Bearer {self.cfg.api_key}"},
|
|
) as r:
|
|
r.raise_for_status()
|
|
async for line in r.aiter_lines():
|
|
if line.startswith("data: "):
|
|
yield line[6:]
|
|
|
|
|
|
def _chunks(s: str, n: int) -> list[str]:
|
|
return [s[i : i + n] for i in range(0, len(s), n)] or [""]
|