feat(voice): the answer streams unless the body opts out

This commit is contained in:
hh
2026-09-07 00:06:46 +02:00
parent ae9c85cbc5
commit 18403e1df9
2 changed files with 36 additions and 9 deletions
+14 -1
View File
@@ -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():
+22 -8
View File
@@ -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)