feat(voice): the answer streams unless the body opts out
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user