feat(transcribe,telegram,config): voice notes, round videos and audio get a transcript from pluggable providers, an openai-compatible one built in
This commit is contained in:
1 parent
944b68e275
commit
42a7058f75
12 files changed
+454
-28
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver_gateway import config
|
||||
|
||||
|
||||
@@ -14,3 +16,15 @@ def test_config_imports_sibling_modules():
|
||||
)
|
||||
gw = config.load(root / "config.py")
|
||||
assert gw.agents == []
|
||||
|
||||
|
||||
def test_config_takes_transcribers_and_refuses_what_cannot_be_called():
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-cfg-"))
|
||||
(root / "config.py").write_text(
|
||||
"gateway = Gateway(transcribers=[OpenAITranscriber(language='ru')])\n"
|
||||
)
|
||||
(gw_stt,) = config.load(root / "config.py").transcribers
|
||||
assert gw_stt.language == "ru" and gw_stt.model == "whisper-1"
|
||||
(root / "config.py").write_text("gateway = Gateway(transcribers=['whisper'])\n")
|
||||
with pytest.raises(config.ConfigError, match=r"transcribers\[0\]"):
|
||||
config.load(root / "config.py")
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -29,6 +30,7 @@ from beaver_gateway.frontends.telegram.render import (
|
||||
to_html_tail,
|
||||
)
|
||||
from beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate
|
||||
from beaver_gateway.transcribe import Audio
|
||||
from sqlmodel import select
|
||||
from test_conversations import ScriptedClient, StubFrontend, World
|
||||
|
||||
@@ -871,6 +873,91 @@ def test_sweep_drops_old_files_and_empty_day_folders() -> None:
|
||||
assert not old.parent.exists() and fresh.exists()
|
||||
|
||||
|
||||
def _voice(caption: str) -> dict[str, Any]:
|
||||
return {
|
||||
"caption": caption,
|
||||
"voice": {
|
||||
"file_id": "v1",
|
||||
"file_unique_id": "vu1",
|
||||
"duration": 3,
|
||||
"mime_type": "audio/ogg",
|
||||
"file_size": 4096,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _prompt_with(needle: str) -> str:
|
||||
return next(p for c in ScriptedClient.instances for p in c.prompts if needle in p)
|
||||
|
||||
|
||||
async def test_voice_without_transcribers_keeps_the_plain_note(stack: Stack) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-voice-"))
|
||||
stack.tg.attachments = Attachments(dir=root, keep_days=3)
|
||||
stack.bot.media(_voice("слушай"))
|
||||
await stack.until(lambda: stack.sent_with("слушай"), what="reply")
|
||||
(saved,) = [p for p in root.rglob("*") if p.is_file()]
|
||||
assert saved.name.endswith("-vu1.ogg")
|
||||
assert _prompt_with("слушай").endswith(
|
||||
f"слушай\n\n[attachment: voice message {saved}, 4 KB; kept 3 days, "
|
||||
"move it into the vault if it matters]"
|
||||
)
|
||||
|
||||
|
||||
async def test_voice_and_video_note_carry_the_transcript(stack: Stack) -> None:
|
||||
heard: list[Audio] = []
|
||||
|
||||
async def stt(audio: Audio) -> str | None:
|
||||
heard.append(audio)
|
||||
return f"сказано {len(heard)}"
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-voice-"))
|
||||
stack.tg.attachments = Attachments(dir=root, keep_days=None)
|
||||
stack.tg._runtime = dataclasses.replace( # noqa: SLF001
|
||||
stack.tg._runtime, # noqa: SLF001
|
||||
transcribers=(stt,),
|
||||
)
|
||||
stack.bot.media(_voice("раз"))
|
||||
await stack.until(lambda: stack.sent_with("раз"), what="voice reply")
|
||||
(voice,) = heard
|
||||
assert voice.media_type == "audio/ogg" and voice.duration == 3
|
||||
assert voice.path.name.endswith("-vu1.ogg") and voice.path.read_bytes() == b"data"
|
||||
assert _prompt_with("раз").endswith(
|
||||
f"раз\n\n[attachment: voice message {voice.path}, 4 KB]\ntranscript: сказано 1"
|
||||
)
|
||||
stack.bot.media(
|
||||
{
|
||||
"video_note": {
|
||||
"file_id": "n1",
|
||||
"file_unique_id": "nu1",
|
||||
"length": 240,
|
||||
"duration": 5,
|
||||
}
|
||||
}
|
||||
)
|
||||
await stack.until(lambda: stack.sent_with("сказано 2"), what="video note reply")
|
||||
assert (heard[1].media_type, heard[1].duration) == ("video/mp4", 5)
|
||||
stack.bot.media(_photo("фото"))
|
||||
await stack.until(lambda: stack.sent_with("фото"), what="photo reply")
|
||||
assert len(heard) == 2 and "transcript" not in _prompt_with("фото")
|
||||
|
||||
|
||||
async def test_a_hung_transcriber_does_not_hold_the_turn(stack: Stack) -> None:
|
||||
async def hung(_: Audio) -> str:
|
||||
await asyncio.sleep(10)
|
||||
return "поздно"
|
||||
|
||||
stack.tg.transcribe_timeout = 0.05
|
||||
stack.tg._runtime = dataclasses.replace( # noqa: SLF001
|
||||
stack.tg._runtime, # noqa: SLF001
|
||||
transcribers=(hung,),
|
||||
)
|
||||
stack.bot.media(_voice("жду"))
|
||||
await stack.until(lambda: stack.sent_with("жду"), what="reply")
|
||||
prompt = _prompt_with("жду")
|
||||
assert prompt.endswith(" KB; kept 7 days, move it into the vault if it matters]")
|
||||
assert "transcript" not in prompt
|
||||
|
||||
|
||||
async def test_topic_rename_updates_the_title(stack: Stack) -> None:
|
||||
stack.bot.topic_created("план", 7)
|
||||
stack.bot.message("hello", thread=7)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aioresponses import CallbackResult, aioresponses
|
||||
|
||||
from beaver_gateway.transcribe import Audio, OpenAITranscriber, transcribe
|
||||
|
||||
URL = "http://whisper.local/v1/audio/transcriptions"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audio() -> Audio:
|
||||
path = Path(tempfile.mkdtemp(prefix="beaver-stt-")) / "u1.ogg"
|
||||
path.write_bytes(b"OggS")
|
||||
return Audio(path, "audio/ogg", 3)
|
||||
|
||||
|
||||
def saying(text: str | None, calls: list[str] | None = None):
|
||||
async def transcriber(audio: Audio) -> str | None:
|
||||
if calls is not None:
|
||||
calls.append(text or "")
|
||||
return text
|
||||
|
||||
return transcriber
|
||||
|
||||
|
||||
async def broken(audio: Audio) -> str | None:
|
||||
msg = "provider down"
|
||||
raise ConnectionError(msg)
|
||||
|
||||
|
||||
async def test_the_first_non_empty_text_wins(audio: Audio) -> None:
|
||||
calls: list[str] = []
|
||||
chain = [saying(None, calls), saying(" \n", calls), saying(" привет ", calls)]
|
||||
chain.append(saying("never", calls))
|
||||
assert await transcribe(chain, audio) == "привет"
|
||||
assert calls == ["", " \n", " привет "]
|
||||
|
||||
|
||||
async def test_a_raising_provider_is_skipped(audio: Audio, caplog) -> None:
|
||||
assert await transcribe([broken, saying("ok")], audio) == "ok"
|
||||
(record,) = [r for r in caplog.records if "provider down" in r.getMessage()]
|
||||
assert record.levelname == "WARNING" and record.exc_info is None
|
||||
|
||||
|
||||
async def test_nothing_comes_back_as_none(audio: Audio) -> None:
|
||||
assert await transcribe([], audio) is None
|
||||
assert await transcribe([broken, saying("")], audio) is None
|
||||
|
||||
|
||||
async def test_openai_transcriber_posts_the_file_and_reads_the_text(
|
||||
audio: Audio,
|
||||
) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def answer(url, **kwargs: Any) -> CallbackResult:
|
||||
fields = kwargs["data"]._fields
|
||||
seen["headers"] = kwargs["headers"]
|
||||
seen["fields"] = {f[0]["name"]: f[2] for f in fields}
|
||||
seen["file"] = next(f for f in fields if f[0]["name"] == "file")
|
||||
return CallbackResult(payload={"text": " привет "})
|
||||
|
||||
stt = OpenAITranscriber(
|
||||
base_url="http://whisper.local/v1/", api_key="sk", model="m", language="ru"
|
||||
)
|
||||
with aioresponses() as http:
|
||||
http.post(URL, callback=answer)
|
||||
assert await stt(audio) == " привет "
|
||||
assert seen["headers"] == {"Authorization": "Bearer sk"}
|
||||
assert seen["fields"]["model"] == "m"
|
||||
assert seen["fields"]["language"] == "ru"
|
||||
assert seen["fields"]["response_format"] == "json"
|
||||
options, headers, _ = seen["file"]
|
||||
assert options["filename"] == "u1.ogg"
|
||||
assert headers["Content-Type"] == "audio/ogg"
|
||||
|
||||
|
||||
async def test_openai_transcriber_without_a_key_sends_no_auth(audio: Audio) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def answer(url, **kwargs: Any) -> CallbackResult:
|
||||
seen["headers"] = kwargs["headers"]
|
||||
seen["fields"] = [f[0]["name"] for f in kwargs["data"]._fields]
|
||||
return CallbackResult(payload={"text": ""})
|
||||
|
||||
with aioresponses() as http:
|
||||
http.post(URL, callback=answer)
|
||||
assert (
|
||||
await OpenAITranscriber(base_url="http://whisper.local/v1")(audio) is None
|
||||
)
|
||||
assert seen["headers"] == {}
|
||||
assert "language" not in seen["fields"]
|
||||
|
||||
|
||||
async def test_openai_transcriber_error_reaches_the_chain(audio: Audio) -> None:
|
||||
stt = OpenAITranscriber(base_url="http://whisper.local/v1")
|
||||
with aioresponses() as http:
|
||||
http.post(URL, status=401, payload={"error": "bad key"})
|
||||
assert await transcribe([stt, saying("fallback")], audio) == "fallback"
|
||||
Reference in new issue
Block a user