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:
hh committed 2026-10-28 02:09:26 +00:00
1 parent 944b68e275
commit 42a7058f75
12 files changed
+454 -28

No files matched your search

+87
View File
@@ -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)