3838from druks .sandbox .layout import get_remote_home , get_work_root
3939from druks .sandbox .models import SandboxIdentity , SecretRef
4040from druks .sandbox .templates import get_template_id
41+ from druks .services .exceptions import ServiceNotConnectedError
4142from druks .workspaces import Workspace
4243
4344from .bots .constants import ADMIN_PROMPT , ADMIN_TOOLS
4849 FAILURE_MESSAGE ,
4950 INTERNAL_MESSAGES_PROMPT ,
5051 RESULT_MESSAGE ,
52+ TRANSCRIPTION_FAILED_MESSAGE ,
53+ VOICE_NOTE_MARKER ,
5154)
5255from .enums import MessageRole , MessageState
53- from .exceptions import ChatBridgeError , ChatHarnessError , ChatSandboxGone
56+ from .exceptions import ChatBridgeError , ChatHarnessError , ChatSandboxGone , TranscriptionError
5457from .models import Conversation , Message
5558from .sandbox import CHAT_SANDBOX
59+ from .services import SpeechToText
5660
5761logger = logging .getLogger (__name__ )
5862
@@ -235,8 +239,8 @@ async def send_turn(
235239 config : AgentConfig ,
236240 prompt : str ,
237241) -> Message | None :
238- """Start the agent and send the pending messages.
239- Return the turn's message, unless a Stop or pause came first."""
242+ """Start the agent, transcribe the pending voice notes, and send the pending
243+ messages. Return the turn's message, unless a Stop or pause came first."""
240244 host = bridge .host
241245 status = await bridge .request ("status" , conversationId = conversation .id )
242246 if status ["status" ] == "running" :
@@ -274,13 +278,32 @@ async def send_turn(
274278 expires_at = Base .utc_now () + timedelta (seconds = SANDBOX_HOST_LEASE_SECONDS )
275279 await sandbox_client .set_expiry (host_id = host .id , expires_at = expires_at )
276280 identity .expires_at = expires_at
281+ # The account's other conversations write this row too. Release it before the
282+ # transcription calls.
283+ await session .commit ()
277284 messages = [message ]
278285 if conversation .connection :
279286 # The sandbox can take seconds to start, and a person can take the chat over meanwhile.
280287 if await conversation .is_held (session ):
281288 return
282289 messages = await conversation .list_pending_messages (session )
283- delivered_messages = [pending for pending in messages if await pending .mark_delivered (session )]
290+ notes = []
291+ for pending in messages :
292+ file = pending .file
293+ if file and file .content_type .startswith ("audio/" ):
294+ try :
295+ pending .transcript = await get_transcript (session , file )
296+ except (TranscriptionError , ServiceNotConnectedError ) as error :
297+ logger .warning ("Chat message %s has no transcript: %s" , pending .id , error )
298+ notes .append (
299+ await conversation .create_message (
300+ session , TRANSCRIPTION_FAILED_MESSAGE , is_internal = True
301+ )
302+ )
303+ # The notes are the newest messages, so they close the turn.
304+ delivered_messages = [
305+ pending for pending in (* messages , * notes ) if await pending .mark_delivered (session )
306+ ]
284307 await session .commit ()
285308 if delivered_messages :
286309 await bridge .request (
@@ -295,16 +318,33 @@ async def send_turn(
295318 return
296319
297320
321+ async def get_transcript (session : AsyncSession , file : File ) -> str :
322+ """The words in a voice note, from the Speech To Text card. Druks refuses a note
323+ over the upload cap before the call."""
324+ if file .size > MAX_UPLOAD_BYTES :
325+ raise TranscriptionError (
326+ f"The voice note is { file .size } bytes. The cap is { MAX_UPLOAD_BYTES } bytes."
327+ )
328+ content = get_file_storage ().open (file .id )
329+ return await SpeechToText .transcribe (
330+ session , name = file .name , content_type = file .content_type , content = content
331+ )
332+
333+
298334async def get_turn_content (
299335 host : Host , conversation_root : str , messages : list [Message ]
300336) -> list [dict ]:
301- """The ACP content blocks the agent reads: each message's text, then its file. An
302- image travels in the prompt. Audio adds nothing. Any other file goes to the
303- conversation's folder in the sandbox, and the agent gets a link to it."""
337+ """The ACP content blocks the agent reads: each message's text, with the words of
338+ its voice note under a marker, then its file. An image travels in the prompt. Audio
339+ adds nothing more. Any other file goes to the conversation's folder in the sandbox,
340+ and the agent gets a link to it."""
304341 content = []
305342 for message in messages :
306- if message .body :
307- content .append ({"type" : "text" , "text" : message .body })
343+ parts = [message .body ]
344+ if message .transcript :
345+ parts += [VOICE_NOTE_MARKER , message .transcript ]
346+ if text := "\n " .join (part for part in parts if part ):
347+ content .append ({"type" : "text" , "text" : text })
308348 if file := message .file :
309349 if file .content_type .startswith ("image/" ) and file .size <= MAX_UPLOAD_BYTES :
310350 image = get_file_storage ().open (file .id )
0 commit comments