From 18403e1df9eea8683e4a3fa0cfebf8f298272f61 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 7 Sep 2026 00:06:46 +0200 Subject: [PATCH] feat(voice): the answer streams unless the body opts out --- beaver_agent/voice/frontend.py | 15 ++++++++++++++- tests/test_voice.py | 30 ++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/beaver_agent/voice/frontend.py b/beaver_agent/voice/frontend.py index c694b95..bb2c049 100644 --- a/beaver_agent/voice/frontend.py +++ b/beaver_agent/voice/frontend.py @@ -43,6 +43,9 @@ class VoiceFrontend(Frontend): Тёрн идёт мимо очереди, а значит и мимо конверта: справка по vault в этот разговор не попадает даже случайно. В телеграм-ветку вопрос кладётся отдельной репликой, ответ пишется черновиком, как обычно. + + Отвечает SSE по умолчанию - колонке важно начать говорить раньше; + `"stream": false` в теле переключает на один JSON. """ name = FRONTEND @@ -103,7 +106,7 @@ class VoiceFrontend(Frontend): prompt = await self._prompt( conv, texts.HEARD.format(room=room, device=device, text=content) ) - if bool(body.get("stream")): + if _flag(body, "stream", default=True): return StreamingResponse( self._sse(conv, prompt, body), media_type="text/event-stream", @@ -211,6 +214,16 @@ async def _body(request: Request) -> Mapping[str, Any]: return data +def _flag(body: Mapping[str, Any], name: str, *, default: bool) -> bool: + """Флаг из тела; строку разбираем словом, иначе `"false"` включит стрим.""" + value = body.get(name) + if value is None: + return default + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return bool(value) + + def _field(body: Mapping[str, Any], name: str) -> str: value = body.get(name) if not isinstance(value, str) or not value.strip(): diff --git a/tests/test_voice.py b/tests/test_voice.py index 93ef3b1..83227b4 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -33,6 +33,8 @@ ASK = { "request_id": "3fd0d6ed-a81b-489c-a1d2-3b97ba84892b", "content": "ало клод как дела", } +PLAIN = {**ASK, "stream": False} +"""То же, но одним JSON: без поля фронтенд стримит.""" @dataclass @@ -161,7 +163,7 @@ 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) + response = await http.post("/", json=PLAIN, headers=VOICE) assert response.status_code == 200 assert response.json() == { "request_id": ASK["request_id"], @@ -177,9 +179,9 @@ async def test_the_first_ask_opens_the_branch_and_the_next_one_keeps_it() -> Non conversations = FakeConversations() frontend, _ = stand(conversations) async with client(frontend) as http: - first = (await http.post("/", json=ASK, headers=VOICE)).json() + first = (await http.post("/", json=PLAIN, headers=VOICE)).json() again = ( - await http.post("/", json={**ASK, "content": "а время?"}, headers=VOICE) + await http.post("/", json={**PLAIN, "content": "а время?"}, headers=VOICE) ).json() assert first["conversation"] == again["conversation"] assert len(conversations.spawned) == 1 @@ -195,7 +197,7 @@ 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) + await http.post("/", json=PLAIN, headers=VOICE) assert conversations.said == [("conv-2", "🎤 andreii · esp32-1\nало клод как дела")] @@ -204,8 +206,8 @@ async def test_two_rooms_at_once_open_one_branch() -> None: 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), + http.post("/", json=PLAIN, headers=VOICE), + http.post("/", json={**PLAIN, "room": "кухня"}, headers=VOICE), ) assert len(conversations.spawned) == 1 @@ -273,13 +275,13 @@ def test_the_voice_agent_gets_no_recall_and_no_reply_log(tmp_path) -> None: assert not (tmp_path / "реплики").exists() -async def test_a_streaming_ask_gets_the_answer_in_pieces() -> None: +async def test_the_answer_streams_when_the_body_does_not_say_otherwise() -> 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, + http.stream("POST", "/", json=ASK, headers=VOICE) as sse, ): assert sse.status_code == 200 assert sse.headers["content-type"].startswith("text/event-stream") @@ -292,3 +294,15 @@ async def test_a_streaming_ask_gets_the_answer_in_pieces() -> None: assert [f["text"] for _, f in frames[:2]] == ["живу, ", "слушаю"] assert frames[-1][1]["reply"] == "живу, слушаю" assert frames[-1][1]["request_id"] == ASK["request_id"] + + +async def test_the_flag_is_read_as_a_word_not_as_a_truthy_string() -> None: + frontend, _ = stand(FakeConversations()) + cases = ((True, True), ("true", True), (False, False), ("false", False), (0, False)) + async with client(frontend) as http: + for value, streams in cases: + response = await http.post( + "/", json={**ASK, "stream": value}, headers=VOICE + ) + kind = response.headers["content-type"] + assert kind.startswith("text/event-stream") is streams, (value, kind)