From af331e6d912f054a4152b9b130e0326b68ecece6 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 6 Sep 2026 23:23:12 +0200 Subject: [PATCH] feat(voice,agents,memory): voice points get their own branch, agent and keys --- README.md | 34 +++- beaver_agent/agents.py | 15 ++ beaver_agent/frontends.py | 3 + beaver_agent/jobs/__init__.py | 2 + beaver_agent/jobs/voice.py | 14 ++ beaver_agent/memory/recall.py | 8 + beaver_agent/memory/watch.py | 6 +- beaver_agent/voice/__init__.py | 17 ++ beaver_agent/voice/frontend.py | 253 ++++++++++++++++++++++++++++ beaver_agent/voice/texts.py | 38 +++++ beaver_agent/voice/thread.py | 62 +++++++ caddy/site.caddy | 2 +- config.py | 3 +- tests/test_voice.py | 294 +++++++++++++++++++++++++++++++++ 14 files changed, 747 insertions(+), 4 deletions(-) create mode 100644 beaver_agent/jobs/voice.py create mode 100644 beaver_agent/voice/__init__.py create mode 100644 beaver_agent/voice/frontend.py create mode 100644 beaver_agent/voice/texts.py create mode 100644 beaver_agent/voice/thread.py create mode 100644 tests/test_voice.py diff --git a/README.md b/README.md index 1bb266f..1c7c2e3 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,38 @@ Everything is on one port under its own path; Caddy forwards the whole domain as - any Anthropic client: `http://localhost:62990/anthropic` or `https:///anthropic`, model = agent name (`beaver-opus-high` etc) - MCP clients (Claude Desktop, Raycast extension): `/mcp//`, discovery page at `/mcp/` - Obsidian companion plugin: `/md` as the plugin's "Base URL", `/api` for the panel +- voice points around the flat: `POST /voice/` with a key of scope `voice` (see below) - **Admin UI:** `http://localhost:62990/` or `https:///`, login from `ADMIN_USER` / `ADMIN_PASS` +## Voice points (`beaver_agent/voice/`) + +ESP32 boxes with microphones around the flat, built by a neighbour, post +their own JSON and get an answer back: + +```sh +curl -X POST https:///voice/ -H "X-Api-Key: " -d '{ + "room": "andreii", "device": "esp32-1", + "request_id": "3fd0d6ed-...", "content": "ало клод как дела"}' +{"request_id":"3fd0d6ed-...","room":"andreii","device":"esp32-1", + "reply":"живу, слушаю","conversation":"…"} +``` + +`"stream": true` in the body answers `text/event-stream` instead: `delta` +frames with pieces of the text, `done` with the same body as above. + +- **One branch for the whole flat**, titled 🎤 Голосовой, so Telegram gives + it a topic under that name: what was asked and what was answered is + readable there, and writing in that topic talks to the same agent. + A cron at 04:05 starts the branch over in the same topic, the way the + master rotates; a day nobody spoke in is left alone. +- **Its own agent** (`beaver-voice`, haiku): `cwd=/tmp`, no tools, no MCP + hands, no gateway tools, and a prompt that ships in this repo instead of + the vault. A stranger may be standing at the speaker, so the agent has + nothing to leak rather than instructions not to - and `Recall`/`ReplyLog` + are muted for it, so the envelope never hands it pointers into the vault. +- **Its own keys**: scope `voice` (Tokens → Create), which opens nothing + else - not `/api`, not the panel, not the token page. + ## Exposing to the internet `caddy/site.caddy` holds the routes (one `reverse_proxy`, domain from `BEAVER_DOMAIN`); on a shared server it is bind-mounted into that server's Caddy as `projects.d/beaver-agent/`, on its own box `caddy/docker-compose.yml` runs a Caddy around it. @@ -156,7 +186,9 @@ pieces live in `beaver_agent/`, one concern per module: - `agents.py` - the roles (dispatcher, deep, distiller, curator, triage, raycast) and the list of instances with models and effort - `frontends.py` - the windows: Telegram for master and branches, vault - files for deep chats, `/api`, `/admin`, `/anthropic`, `/mcp` + files for deep chats, `/api`, `/admin`, `/anthropic`, `/mcp`, `/voice` +- `voice/` - the voice points: the window, the branch and its nightly + rotation, the prompt the cheap agent runs on - `texts.py` - everything the gateway says to the model or to the user, in Russian (the gateway's own defaults are English) - `memory/` - the recall block under a message, the reply log, handouts diff --git a/beaver_agent/agents.py b/beaver_agent/agents.py index 83bb081..199c523 100644 --- a/beaver_agent/agents.py +++ b/beaver_agent/agents.py @@ -21,6 +21,8 @@ from beaver_agent.policy import ( vault_zones, ) from beaver_agent.vault import TZ, VAULT +from beaver_agent.voice import AGENT as VOICE +from beaver_agent.voice import PROMPT as VOICE_PROMPT if TYPE_CHECKING: from beaver_gateway.agents.base import BaseAgent @@ -106,6 +108,18 @@ def triage(name: str, model: str, effort: str) -> ClaudeAgent: ) +def voice(name: str, model: str) -> ClaudeAgent: + """Колонки по квартире: чужие люди, поэтому ни vault, ни инструментов.""" + return ClaudeAgent( + name=name, + model=model, + cwd=Path("/tmp"), # noqa: S108 + system_prompt=VOICE_PROMPT, + kinds=("branch",), + options=ClaudeOptions(tools=(), disallowed_tools=TRIAGE_DISALLOWED), + ) + + def raycast(name: str, model: str, effort: str) -> RaycastAgent: """Быстрые ответы через Raycast: веб и чтение страниц, vault через obsidian-fs.""" return RaycastAgent( @@ -131,6 +145,7 @@ agents: list[BaseAgent] = [ distiller("beaver-distiller", "claude-opus-5", "medium"), curator("beaver-curator", "claude-opus-5", "high"), triage("beaver-triage", "claude-sonnet-5", "low"), + voice(VOICE, "claude-haiku-4-5-20251001"), raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"), raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", "low"), raycast("beaver-gemini-flash-high", "google-gemini-3.5-flash", "high"), diff --git a/beaver_agent/frontends.py b/beaver_agent/frontends.py index c8785a7..1a62fd9 100644 --- a/beaver_agent/frontends.py +++ b/beaver_agent/frontends.py @@ -16,6 +16,8 @@ from beaver_gateway.frontends.turn_record import slugify from beaver_agent import texts from beaver_agent.vault import ATTACHMENTS, BEAVER, CHATS, TZ, VAULT +from beaver_agent.voice import AGENT as VOICE +from beaver_agent.voice import VoiceFrontend if TYPE_CHECKING: from pathlib import Path @@ -62,4 +64,5 @@ frontends: list[Frontend] = [ MarkdownFrontend( vault_path=CHATS, default_agent=DEEP, log_all_chats=True, chat_path=chat_path ), + VoiceFrontend(agent=VOICE), ] diff --git a/beaver_agent/jobs/__init__.py b/beaver_agent/jobs/__init__.py index 36d71a8..46780ac 100644 --- a/beaver_agent/jobs/__init__.py +++ b/beaver_agent/jobs/__init__.py @@ -11,12 +11,14 @@ from beaver_agent.jobs.memory import curate, memory from beaver_agent.jobs.rotation import rotate from beaver_agent.jobs.t3code import t3code_event from beaver_agent.jobs.vibegram import vibegram +from beaver_agent.jobs.voice import rotate_voice jobs = [ Job("ротация", rotate, cron="0 * * * *"), Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False), Job("закрытие", close_idle, cron="20 4 * * *", critical=False), Job("память", memory, cron="30 4 * * 0", critical=False), + Job("голосовой", rotate_voice, cron="5 4 * * *", critical=False), Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False), Job("deploy", deploy, webhook=True), Job("komodo", komodo_alert, webhook=True, dedupe=False), diff --git a/beaver_agent/jobs/voice.py b/beaver_agent/jobs/voice.py new file mode 100644 index 0000000..91ddee5 --- /dev/null +++ b/beaver_agent/jobs/voice.py @@ -0,0 +1,14 @@ +"""Раз в ночь: голосовая ветка начинается заново в том же топике.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from beaver_agent.voice import rotate + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + + +async def rotate_voice(run: JobRun) -> None: + await rotate(run.conversations) diff --git a/beaver_agent/memory/recall.py b/beaver_agent/memory/recall.py index 81085ac..cfb87c0 100644 --- a/beaver_agent/memory/recall.py +++ b/beaver_agent/memory/recall.py @@ -198,6 +198,8 @@ class Recall: tz: str = "Europe/Warsaw" diary_scan: int = 400 """Сколько последних дней дневника просматривать на упоминания.""" + mute: frozenset[str] = frozenset() + """Агенты, которым справку не показывают: чужие люди, чужие уши.""" _people: People | None = field(default=None, init=False, repr=False) _diary_cache: dict[str, tuple[float, list[str]]] = field( default_factory=dict, init=False, repr=False @@ -211,6 +213,8 @@ class Recall: return self._people def block(self, ctx: RecallContext) -> str | None: + if ctx.agent in self.mute: + return None today = ctx.now.astimezone(ZoneInfo(self.tz)).date() lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())] if ctx.kind == "master": @@ -306,8 +310,12 @@ class ReplyLog: dir: Path tz: str = "Europe/Warsaw" max_chars: int = 600 + mute: frozenset[str] = frozenset() + """Агенты, чьи разговоры в лог реплик не идут.""" def write(self, said: UserSaid) -> None: + if said.agent in self.mute: + return text = said.text if text.startswith("[сид:"): # спавн ветки с текстом: строка сида едет над самим сообщением diff --git a/beaver_agent/memory/watch.py b/beaver_agent/memory/watch.py index de03c05..13594de 100644 --- a/beaver_agent/memory/watch.py +++ b/beaver_agent/memory/watch.py @@ -18,6 +18,7 @@ from beaver_agent.vault import ( TZ, VAULT, ) +from beaver_agent.voice import AGENT as VOICE watch = VaultWatch( VAULT, @@ -52,8 +53,11 @@ recall = Recall( diary_dir=DIARY, boards_dir=BOARDS, tz=TZ, + mute=frozenset({VOICE}), ) -replies = ReplyLog(REPLIES, tz=TZ) +"""Голосовому агенту справки нет: за колонкой может стоять кто угодно.""" + +replies = ReplyLog(REPLIES, tz=TZ, mute=frozenset({VOICE})) CURATOR_WATCH = [ Watched("хендауты", DAYS, "????-??-??.md"), diff --git a/beaver_agent/voice/__init__.py b/beaver_agent/voice/__init__.py new file mode 100644 index 0000000..aad55ba --- /dev/null +++ b/beaver_agent/voice/__init__.py @@ -0,0 +1,17 @@ +"""Голосовые точки по квартире: своя ветка, свой дешёвый агент, свои ключи.""" + +from beaver_agent.voice.frontend import VoiceFrontend +from beaver_agent.voice.texts import KEY, PROMPT, TITLE +from beaver_agent.voice.thread import AGENT, FRONTEND, current, open_thread, rotate + +__all__ = [ + "AGENT", + "FRONTEND", + "KEY", + "PROMPT", + "TITLE", + "VoiceFrontend", + "current", + "open_thread", + "rotate", +] diff --git a/beaver_agent/voice/frontend.py b/beaver_agent/voice/frontend.py new file mode 100644 index 0000000..c694b95 --- /dev/null +++ b/beaver_agent/voice/frontend.py @@ -0,0 +1,253 @@ +"""Окно голосовых точек: ESP32 шлёт json на /voice, отвечает дешёвый агент.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from beaver_gateway.agents.claude import ClaudeAgent +from beaver_gateway.conversations.turns import content_of +from beaver_gateway.frontends.accumulate import StreamAccumulator +from beaver_gateway.frontends.base import Frontend +from beaver_gateway.frontends.bearer import require_token +from beaver_gateway.frontends.sse import ( + KEEPALIVE, + SSE_HEADERS, + events_with_heartbeat, + sse_pack, +) +from beaver_gateway.security import audit +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.responses import JSONResponse, StreamingResponse + +from beaver_agent.voice import texts +from beaver_agent.voice.thread import FRONTEND, current, open_thread + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping + + from beaver_gateway.frontends.base import GatewayRuntime + from beaver_gateway.storage.models import Conversation + +_log = logging.getLogger("beaver_agent.voice") + +FIELDS = ("room", "device", "content") + + +class VoiceFrontend(Frontend): + """Одна ветка на всю квартиру, свой агент, свои ключи. + + Тёрн идёт мимо очереди, а значит и мимо конверта: справка по vault в + этот разговор не попадает даже случайно. В телеграм-ветку вопрос + кладётся отдельной репликой, ответ пишется черновиком, как обычно. + """ + + name = FRONTEND + kinds = ("branch",) + path = "/voice" + scope = "voice" + + def __init__(self, *, agent: str, timeout: float = 90.0) -> None: + self.agent = agent + self.timeout = timeout + """Сколько ждать ответа; дальше 504, а тёрн договаривает в телеграм.""" + self._runtime: GatewayRuntime | None = None + self._app: FastAPI | None = None + self._lock = asyncio.Lock() + self._late: set[asyncio.Task[Any]] = set() + + def configure(self, runtime: GatewayRuntime) -> None: + if runtime.conversations is None or runtime.bus is None: + msg = "VoiceFrontend: нужны runtime.conversations и runtime.bus" + raise RuntimeError(msg) + agent = runtime.agents.get(self.agent) + if not isinstance(agent, ClaudeAgent) or not agent.serves("branch"): + msg = f"VoiceFrontend: агент {self.agent!r} не обслуживает ветки" + raise RuntimeError(msg) + self._runtime = runtime + self._app = self._build_app(runtime) + + def app(self) -> FastAPI | None: + return self._app + + def _build_app(self, runtime: GatewayRuntime) -> FastAPI: + app = FastAPI(title="beaver-agent / голосовой") + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok", "frontend": self.name} + + @app.post("/") + async def ask(request: Request) -> Any: + token = await require_token(request, runtime, scope=self.scope) + body = await _body(request) + room, device, content = (_field(body, name) for name in FIELDS) + conv = await self.thread() + await audit.log( + runtime, + actor=f"token:{token}", + kind="voice_ask", + agent_name=conv.agent_name, + conversation=conv.external_id, + room=room, + device=device, + request_id=body.get("request_id"), + ) + _log.info("голос: %s/%s -> %s", room, device, conv.external_id) + await runtime.conversations.say( + conv, texts.ASKED.format(room=room, device=device, text=content) + ) + prompt = await self._prompt( + conv, texts.HEARD.format(room=room, device=device, text=content) + ) + if bool(body.get("stream")): + return StreamingResponse( + self._sse(conv, prompt, body), + media_type="text/event-stream", + headers=SSE_HEADERS, + ) + return JSONResponse(await self._answer(conv, prompt, body)) + + @app.exception_handler(HTTPException) + async def http_error(_request: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + headers=exc.headers, + ) + + return app + + async def thread(self) -> Conversation: + conversations = self.runtime.conversations + async with self._lock: + conv = await current(conversations) + if conv is not None: + return conv + return await open_thread(conversations, agent=self.agent) + + @property + def runtime(self) -> GatewayRuntime: + if self._runtime is None: + msg = "VoiceFrontend: configure() не вызывали" + raise RuntimeError(msg) + return self._runtime + + async def _prompt(self, conv: Conversation, text: str) -> str: + seed = await self.runtime.conversations.pending_seed(conv) + return f"{seed}\n\n{text}" if seed else text + + async def _answer( + self, conv: Conversation, prompt: str, body: Mapping[str, Any] + ) -> dict[str, Any]: + turn_id = _turn_id() + task = asyncio.create_task(self._run(conv, prompt, turn_id)) + self._late.add(task) + task.add_done_callback(self._late.discard) + done, _ = await asyncio.wait({task}, timeout=self.timeout) + if not done: + _log.warning("голос: %s не уложился в %.0f c", turn_id, self.timeout) + raise HTTPException( + status.HTTP_504_GATEWAY_TIMEOUT, "агент не успел; ответ уйдёт в ветку" + ) + return _body_out(body, reply=task.result(), conv=conv) + + async def _run(self, conv: Conversation, prompt: str, turn_id: str) -> str: + reply, _capture = await self.runtime.conversations.run_text_turn( + conv, prompt, origin="user", turn_id=turn_id + ) + self._published(conv, reply=reply, turn_id=turn_id) + return reply + + async def _sse( + self, conv: Conversation, prompt: str, body: Mapping[str, Any] + ) -> AsyncIterator[bytes]: + turn_id = _turn_id() + acc = StreamAccumulator() + model = self.runtime.agents[conv.agent_name].model + try: + events = self.runtime.conversations.turn( + conv, + messages=[{"role": "user", "content": content_of(prompt, None)}], + origin="user", + turn_id=turn_id, + ) + async for event in events_with_heartbeat(events): + if event is None: + yield KEEPALIVE + continue + acc.feed(event) + chunk = _text_delta(event) + if chunk: + yield sse_pack("delta", {"text": chunk}) + except Exception as exc: # noqa: BLE001 + _log.exception("голос: тёрн %s упал", turn_id) + yield sse_pack("error", {"error": str(exc)}) + return + reply = _text_of(acc.finalize(model=model)) + self._published(conv, reply=reply, turn_id=turn_id) + yield sse_pack("done", _body_out(body, reply=reply, conv=conv)) + + def _published(self, conv: Conversation, *, reply: str, turn_id: str) -> None: + self.runtime.bus.publish( + "reply", + conversation_id=conv.external_id, + turn_id=turn_id, + source=self.name, + text=reply, + ) + + +async def _body(request: Request) -> Mapping[str, Any]: + try: + data = await request.json() + except json.JSONDecodeError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"кривой json: {exc}") from exc + if not isinstance(data, dict): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "тело должно быть объектом") + return data + + +def _field(body: Mapping[str, Any], name: str) -> str: + value = body.get(name) + if not isinstance(value, str) or not value.strip(): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, f"нужно непустое строковое поле {name!r}" + ) + return value.strip() + + +def _body_out( + body: Mapping[str, Any], *, reply: str, conv: Conversation +) -> dict[str, Any]: + return { + "request_id": body.get("request_id"), + "room": body.get("room"), + "device": body.get("device"), + "reply": reply, + "conversation": conv.external_id, + } + + +def _turn_id() -> str: + return f"turn_{uuid4().hex[:12]}" + + +def _text_delta(event: Any) -> str: + if getattr(event, "type", "") != "content_block_delta": + return "" + delta = getattr(event, "delta", None) + if getattr(delta, "type", "") != "text_delta": + return "" + return getattr(delta, "text", "") + + +def _text_of(message: Any) -> str: + return "\n\n".join( + getattr(b, "text", "") + for b in message.content + if getattr(b, "type", "") == "text" + ).strip() diff --git a/beaver_agent/voice/texts.py b/beaver_agent/voice/texts.py new file mode 100644 index 0000000..1875ea8 --- /dev/null +++ b/beaver_agent/voice/texts.py @@ -0,0 +1,38 @@ +"""Что голосовой агент читает как промпт и что видно в его телеграм-ветке.""" + +from __future__ import annotations + +TITLE = "🎤 Голосовой" +"""Имя ветки; телеграм заводит топик под этим именем.""" + +KEY = "квартира" +"""Ключ единственного треда: одна ветка на всю квартиру.""" + +PROMPT = """\ +Ты - голос из колонки в квартире. Отвечаешь вслух: одна-две фразы, без +markdown, без списков, без ссылок, без эмодзи. Не знаешь - так и говори, +коротко. + +Перед словами в квадратных скобках - комната и устройство, откуда говорят. +С тобой говорят гости и домашние, кто именно - неизвестно. + +Про хозяина дома ты не знаешь ничего и ничего о нём не рассказываешь: ни +имени, ни дел, ни планов, ни календаря, ни переписок, ни файлов, ни его +знакомых - и даже того, что такие записи где-то есть. Спрашивают о нём - +скажи, что этого не знаешь, и предложи спросить у него самого. Придумывать +вместо ответа нельзя. + +Инструментов у тебя нет: ни файлов, ни поиска, ни календаря, ни умного дома. +Всё, что ты умеешь, - разговаривать. + +Иногда в этой же ветке пишет сам хозяин, из телеграма. Ему отвечаешь так же +коротко, и правила про него не отменяются. +""" + +ASKED = "🎤 {room} · {device}\n{text}" +"""Вопрос, как он показывается в телеграм-ветке.""" + +HEARD = "[{room} · {device}] {text}" +"""Вопрос, как его видит модель.""" + +ROTATED = "🎤 Новый день - ветка начата заново, прошлого разговора агент не помнит." diff --git a/beaver_agent/voice/thread.py b/beaver_agent/voice/thread.py new file mode 100644 index 0000000..0bc28ea --- /dev/null +++ b/beaver_agent/voice/thread.py @@ -0,0 +1,62 @@ +"""Единственная ветка голосовых точек: как её открыть и как сменить за ночь.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from beaver_agent.voice.texts import KEY, ROTATED, TITLE + +if TYPE_CHECKING: + from beaver_gateway.conversations.service import Conversations + from beaver_gateway.storage.models import Conversation + +AGENT = "beaver-voice" +"""Кто сидит в голосовой ветке; ему не видно ни справки, ни vault.""" + +FRONTEND = "voice" +TELEGRAM = "telegram" + +_log = logging.getLogger("beaver_agent.voice") + + +async def current(conversations: Conversations) -> Conversation | None: + conv = await conversations.find_bound(frontend=FRONTEND, external_id=KEY) + return conv if conv is not None and conv.status == "open" else None + + +async def open_thread( + conversations: Conversations, *, agent: str, window: str | None = None +) -> Conversation: + """Завести ветку; без ``window`` окно ей открывает телеграм - новым топиком.""" + conv = await conversations.spawn( + kind="branch", + agent=agent, + title=TITLE, + origin=FRONTEND, + binding=(TELEGRAM, window) if window else None, + ) + await conversations.bind(conv, frontend=FRONTEND, external_id=KEY) + _log.info("голосовая ветка %s (%s)", conv.external_id, agent) + return conv + + +async def window_of(conversations: Conversations, conv: Conversation) -> str | None: + for binding in await conversations.bindings(conv): + if binding.frontend == TELEGRAM and binding.visible: + return binding.external_id + return None + + +async def rotate(conversations: Conversations) -> Conversation | None: + """Ночью - новая ветка в том же топике; молчавшую за сутки не трогаем.""" + old = await current(conversations) + if old is None or old.last_user_activity_at is None: + return None + new = await open_thread( + conversations, agent=old.agent_name, window=await window_of(conversations, old) + ) + await conversations.close(old) + await conversations.say(new, ROTATED) + _log.info("голосовая ветка: %s -> %s", old.external_id, new.external_id) + return new diff --git a/caddy/site.caddy b/caddy/site.caddy index 6d72b3e..c0b37d2 100644 --- a/caddy/site.caddy +++ b/caddy/site.caddy @@ -1,5 +1,5 @@ # Роуты beaver. Один апстрим, префиксы не срезаются: gateway сам живёт под -# /anthropic, /mcp, /md, /api, /admin, а / ведёт в админку. +# /anthropic, /mcp, /md, /api, /admin, /voice, а / ведёт в админку. # Домен и апстрим - из env сервера (domains.env / .env), tls - забота # серверного Caddyfile. {$BEAVER_DOMAIN} { diff --git a/config.py b/config.py index e0cee59..fc1fed7 100644 --- a/config.py +++ b/config.py @@ -3,7 +3,8 @@ - vault.py - пути и зона агента; prompts.py - как из гранул собирается промпт роли; - skills.py - наборы скиллов по окнам; policy.py - что можно трогать в vault; - hands/ - MCP-руки и кому какая выдана; agents.py - роли и их экземпляры; -- frontends.py - окна; texts.py - всё, что gateway говорит, по-русски; +- frontends.py - окна; voice/ - голосовые точки по квартире и их ветка; +- texts.py - всё, что gateway говорит, по-русски; - memory/ - справка, реплики, хендауты, вотчер, куратор; jobs/ - кроны и вебхуки. """ diff --git a/tests/test_voice.py b/tests/test_voice.py new file mode 100644 index 0000000..93ef3b1 --- /dev/null +++ b/tests/test_voice.py @@ -0,0 +1,294 @@ +import asyncio +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any, cast + +import httpx +import pytest +from beaver_gateway.app import AgentRegistry, McpRegistry +from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions +from beaver_gateway.conversations.envelope import RecallContext +from beaver_gateway.events.stream import ( + build_content_block_stop, + build_message_delta, + build_message_start, + build_message_stop, + build_text_block_start, + build_text_delta, +) +from beaver_gateway.conversations.texts import UserSaid +from beaver_gateway.frontends.base import GatewayRuntime +from beaver_gateway.security.auth import TokenStore, scopes_with + +from beaver_agent.memory.recall import Recall, ReplyLog +from beaver_agent.voice import AGENT, KEY, TITLE, VoiceFrontend, rotate +from beaver_agent.voice.texts import ROTATED + +VOICE = {"Authorization": "Bearer voice-key"} +OPS = {"Authorization": "Bearer ops-key"} +ASK = { + "room": "andreii", + "device": "esp32-1", + "request_id": "3fd0d6ed-a81b-489c-a1d2-3b97ba84892b", + "content": "ало клод как дела", +} + + +@dataclass +class Conv: + external_id: str = "conv-1" + agent_name: str = AGENT + kind: str = "branch" + title: str = TITLE + status: str = "open" + last_user_activity_at: datetime | None = None + flags: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Binding: + frontend: str + external_id: str + visible: bool = True + + +class FakeConversations: + """Ровно те методы сервиса, которыми пользуется голосовое окно.""" + + def __init__(self, bound: Conv | None = None) -> None: + self.conv = bound + self.bindings_of: dict[str, list[Binding]] = {} + self.said: list[tuple[str, str]] = [] + self.prompts: list[str] = [] + self.bound: list[tuple[str, str, str]] = [] + self.closed: list[str] = [] + self.spawned: list[dict[str, Any]] = [] + self.reply = "живу, слушаю" + self.spawn_hook = None + + async def find_bound(self, *, frontend: str, external_id: str) -> Conv | None: + if self.conv is None or frontend != "voice" or external_id != KEY: + return None + return self.conv + + async def spawn(self, **kwargs: Any) -> Conv: + self.spawned.append(kwargs) + conv = Conv(external_id=f"conv-{len(self.spawned) + 1}") + self.conv = conv + binding = kwargs.get("binding") + if binding: + self.bindings_of[conv.external_id] = [Binding(*binding)] + return conv + + async def bind(self, conv: Conv, *, frontend: str, external_id: str) -> None: + self.bound.append((conv.external_id, frontend, external_id)) + + async def bindings(self, conv: Conv) -> list[Binding]: + return self.bindings_of.get(conv.external_id, []) + + async def close(self, conv: Conv) -> None: + conv.status = "closed" + self.closed.append(conv.external_id) + + async def say(self, conv: Conv, text: str) -> None: + self.said.append((conv.external_id, text)) + + async def pending_seed(self, conv: Conv) -> str | None: + return conv.flags.pop("seed", None) + + async def run_text_turn(self, conv: Conv, prompt: str, **_: Any) -> tuple[str, Any]: + self.prompts.append(prompt) + return self.reply, None + + async def turn(self, conv: Conv, *, messages: list[Any], **_: Any) -> Any: + self.prompts.append(messages[0]["content"]) + yield build_message_start(message_id="m", model="haiku") + yield build_text_block_start(0) + for chunk in ("живу, ", "слушаю"): + yield build_text_delta(0, chunk) + yield build_content_block_stop(0) + yield build_message_delta(stop_reason="end_turn") + yield build_message_stop() + + +class Bus: + def __init__(self) -> None: + self.events: list[dict[str, Any]] = [] + + def publish(self, type_: str, **data: Any) -> dict[str, Any]: + event = {"type": type_, **data} + self.events.append(event) + return event + + +def stand(conversations: FakeConversations) -> tuple[VoiceFrontend, GatewayRuntime]: + agent = ClaudeAgent( + name=AGENT, + model="claude-haiku-4-5-20251001", + cwd=".", + system_prompt="голос", + kinds=("branch",), + options=ClaudeOptions(tools=()), + ) + frontend = VoiceFrontend(agent=AGENT) + runtime = GatewayRuntime( + agents=AgentRegistry([agent]), + mcps=McpRegistry([]), + backends={}, + token_store=TokenStore( + bootstrap={"voice": "voice-key", "ops": "ops-key"}, + bootstrap_scopes={"voice": "voice", "ops": "api"}, + ), + db=cast("Any", None), + frontends=(frontend,), + conversations=conversations, + bus=Bus(), + scopes=scopes_with(["voice"]), + ) + frontend.configure(runtime) + return frontend, runtime + + +def client(frontend: VoiceFrontend) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=cast("Any", frontend.app())), + base_url="http://t", + ) + + +async def test_the_payload_is_answered_and_its_fields_come_back() -> None: + conversations = FakeConversations() + frontend, _ = stand(conversations) + async with client(frontend) as http: + response = await http.post("/", json=ASK, headers=VOICE) + assert response.status_code == 200 + assert response.json() == { + "request_id": ASK["request_id"], + "room": "andreii", + "device": "esp32-1", + "reply": "живу, слушаю", + "conversation": "conv-2", + } + assert conversations.prompts == ["[andreii · esp32-1] ало клод как дела"] + + +async def test_the_first_ask_opens_the_branch_and_the_next_one_keeps_it() -> None: + conversations = FakeConversations() + frontend, _ = stand(conversations) + async with client(frontend) as http: + first = (await http.post("/", json=ASK, headers=VOICE)).json() + again = ( + await http.post("/", json={**ASK, "content": "а время?"}, headers=VOICE) + ).json() + assert first["conversation"] == again["conversation"] + assert len(conversations.spawned) == 1 + spawn = conversations.spawned[0] + assert spawn["kind"] == "branch" + assert spawn["agent"] == AGENT + assert spawn["title"] == TITLE + assert spawn["binding"] is None + assert conversations.bound == [(first["conversation"], "voice", KEY)] + + +async def test_the_question_lands_in_the_branch_first() -> None: + conversations = FakeConversations() + frontend, _ = stand(conversations) + async with client(frontend) as http: + await http.post("/", json=ASK, headers=VOICE) + assert conversations.said == [("conv-2", "🎤 andreii · esp32-1\nало клод как дела")] + + +async def test_two_rooms_at_once_open_one_branch() -> None: + conversations = FakeConversations() + frontend, _ = stand(conversations) + async with client(frontend) as http: + await asyncio.gather( + http.post("/", json=ASK, headers=VOICE), + http.post("/", json={**ASK, "room": "кухня"}, headers=VOICE), + ) + assert len(conversations.spawned) == 1 + + +async def test_only_a_voice_key_opens_the_route() -> None: + frontend, _ = stand(FakeConversations()) + async with client(frontend) as http: + assert (await http.post("/", json=ASK)).status_code == 401 + assert (await http.post("/", json=ASK, headers=OPS)).status_code == 403 + + +async def test_a_body_without_the_words_is_a_400() -> None: + frontend, _ = stand(FakeConversations()) + async with client(frontend) as http: + for missing in ("room", "device", "content"): + body = {k: v for k, v in ASK.items() if k != missing} + response = await http.post("/", json=body, headers=VOICE) + assert response.status_code == 400 + assert missing in response.json()["error"] + + +async def test_the_night_keeps_the_topic_and_starts_the_branch_over() -> None: + old = Conv(last_user_activity_at=datetime.now(UTC)) + conversations = FakeConversations(bound=old) + conversations.bindings_of["conv-1"] = [Binding("telegram", "42")] + new = await rotate(cast("Any", conversations)) + assert new is not None + assert conversations.spawned[0]["binding"] == ("telegram", "42") + assert conversations.bound == [(new.external_id, "voice", KEY)] + assert conversations.closed == ["conv-1"] + assert conversations.said == [(new.external_id, ROTATED)] + + +async def test_a_branch_nobody_spoke_in_is_left_alone() -> None: + conversations = FakeConversations(bound=Conv()) + assert await rotate(cast("Any", conversations)) is None + assert conversations.spawned == [] + + +def test_the_voice_agent_gets_no_recall_and_no_reply_log(tmp_path) -> None: + recall = Recall( + vault=tmp_path, + people_dir=tmp_path / "люди", + notes_dir=tmp_path / "записки", + diary_dir=tmp_path / "дни", + boards_dir=tmp_path / "доски", + mute=frozenset({AGENT}), + ) + now = datetime.now(UTC) + assert ( + recall.block(RecallContext(text="Прохор", kind="branch", now=now, agent=AGENT)) + is None + ) + log = ReplyLog(tmp_path / "реплики", mute=frozenset({AGENT})) + log.write( + UserSaid( + conversation_id="c", + kind="branch", + title=TITLE, + text="что там у Прохор", + at=now, + agent=AGENT, + ) + ) + assert not (tmp_path / "реплики").exists() + + +async def test_a_streaming_ask_gets_the_answer_in_pieces() -> None: + conversations = FakeConversations() + frontend, _ = stand(conversations) + frames: list[tuple[str, dict[str, Any]]] = [] + async with ( + client(frontend) as http, + http.stream("POST", "/", json={**ASK, "stream": True}, headers=VOICE) as sse, + ): + assert sse.status_code == 200 + assert sse.headers["content-type"].startswith("text/event-stream") + buffer = "".join([chunk async for chunk in sse.aiter_text()]) + for block in buffer.split("\n\n"): + lines = [line for line in block.splitlines() if line] + if len(lines) == 2 and lines[0].startswith("event: "): + frames.append((lines[0][7:], json.loads(lines[1][6:]))) + assert [name for name, _ in frames] == ["delta", "delta", "done"] + assert [f["text"] for _, f in frames[:2]] == ["живу, ", "слушаю"] + assert frames[-1][1]["reply"] == "живу, слушаю" + assert frames[-1][1]["request_id"] == ASK["request_id"]