feat(voice): the answer streams unless the body opts out
This commit is contained in:
@@ -43,6 +43,9 @@ class VoiceFrontend(Frontend):
|
|||||||
Тёрн идёт мимо очереди, а значит и мимо конверта: справка по vault в
|
Тёрн идёт мимо очереди, а значит и мимо конверта: справка по vault в
|
||||||
этот разговор не попадает даже случайно. В телеграм-ветку вопрос
|
этот разговор не попадает даже случайно. В телеграм-ветку вопрос
|
||||||
кладётся отдельной репликой, ответ пишется черновиком, как обычно.
|
кладётся отдельной репликой, ответ пишется черновиком, как обычно.
|
||||||
|
|
||||||
|
Отвечает SSE по умолчанию - колонке важно начать говорить раньше;
|
||||||
|
`"stream": false` в теле переключает на один JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = FRONTEND
|
name = FRONTEND
|
||||||
@@ -103,7 +106,7 @@ class VoiceFrontend(Frontend):
|
|||||||
prompt = await self._prompt(
|
prompt = await self._prompt(
|
||||||
conv, texts.HEARD.format(room=room, device=device, text=content)
|
conv, texts.HEARD.format(room=room, device=device, text=content)
|
||||||
)
|
)
|
||||||
if bool(body.get("stream")):
|
if _flag(body, "stream", default=True):
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
self._sse(conv, prompt, body),
|
self._sse(conv, prompt, body),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
@@ -211,6 +214,16 @@ async def _body(request: Request) -> Mapping[str, Any]:
|
|||||||
return data
|
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:
|
def _field(body: Mapping[str, Any], name: str) -> str:
|
||||||
value = body.get(name)
|
value = body.get(name)
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
|||||||
+22
-8
@@ -33,6 +33,8 @@ ASK = {
|
|||||||
"request_id": "3fd0d6ed-a81b-489c-a1d2-3b97ba84892b",
|
"request_id": "3fd0d6ed-a81b-489c-a1d2-3b97ba84892b",
|
||||||
"content": "ало клод как дела",
|
"content": "ало клод как дела",
|
||||||
}
|
}
|
||||||
|
PLAIN = {**ASK, "stream": False}
|
||||||
|
"""То же, но одним JSON: без поля фронтенд стримит."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -161,7 +163,7 @@ async def test_the_payload_is_answered_and_its_fields_come_back() -> None:
|
|||||||
conversations = FakeConversations()
|
conversations = FakeConversations()
|
||||||
frontend, _ = stand(conversations)
|
frontend, _ = stand(conversations)
|
||||||
async with client(frontend) as http:
|
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.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"request_id": ASK["request_id"],
|
"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()
|
conversations = FakeConversations()
|
||||||
frontend, _ = stand(conversations)
|
frontend, _ = stand(conversations)
|
||||||
async with client(frontend) as http:
|
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 = (
|
again = (
|
||||||
await http.post("/", json={**ASK, "content": "а время?"}, headers=VOICE)
|
await http.post("/", json={**PLAIN, "content": "а время?"}, headers=VOICE)
|
||||||
).json()
|
).json()
|
||||||
assert first["conversation"] == again["conversation"]
|
assert first["conversation"] == again["conversation"]
|
||||||
assert len(conversations.spawned) == 1
|
assert len(conversations.spawned) == 1
|
||||||
@@ -195,7 +197,7 @@ async def test_the_question_lands_in_the_branch_first() -> None:
|
|||||||
conversations = FakeConversations()
|
conversations = FakeConversations()
|
||||||
frontend, _ = stand(conversations)
|
frontend, _ = stand(conversations)
|
||||||
async with client(frontend) as http:
|
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ало клод как дела")]
|
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)
|
frontend, _ = stand(conversations)
|
||||||
async with client(frontend) as http:
|
async with client(frontend) as http:
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
http.post("/", json=ASK, headers=VOICE),
|
http.post("/", json=PLAIN, headers=VOICE),
|
||||||
http.post("/", json={**ASK, "room": "кухня"}, headers=VOICE),
|
http.post("/", json={**PLAIN, "room": "кухня"}, headers=VOICE),
|
||||||
)
|
)
|
||||||
assert len(conversations.spawned) == 1
|
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()
|
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()
|
conversations = FakeConversations()
|
||||||
frontend, _ = stand(conversations)
|
frontend, _ = stand(conversations)
|
||||||
frames: list[tuple[str, dict[str, Any]]] = []
|
frames: list[tuple[str, dict[str, Any]]] = []
|
||||||
async with (
|
async with (
|
||||||
client(frontend) as http,
|
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.status_code == 200
|
||||||
assert sse.headers["content-type"].startswith("text/event-stream")
|
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 [f["text"] for _, f in frames[:2]] == ["живу, ", "слушаю"]
|
||||||
assert frames[-1][1]["reply"] == "живу, слушаю"
|
assert frames[-1][1]["reply"] == "живу, слушаю"
|
||||||
assert frames[-1][1]["request_id"] == ASK["request_id"]
|
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