@@ -397,7 +397,7 @@ async fn transcribe_via_interactions(
397397
398398 let parsed: InteractionsResponse = serde_json:: from_str ( & response)
399399 . with_context ( || format ! ( "parsing Interactions response: {response}" ) ) ?;
400- non_empty_transcription ( parsed. text ( ) )
400+ non_empty_transcription ( parsed. text ( ) , & response )
401401}
402402
403403/// Transcribe with a general-purpose model. Thinking is switched off: it buys
@@ -424,7 +424,7 @@ async fn transcribe_via_generate_content(
424424
425425 let parsed: GenerateContentResponse = serde_json:: from_str ( & response)
426426 . with_context ( || format ! ( "parsing Gemini response: {response}" ) ) ?;
427- non_empty_transcription ( parsed. text ( ) )
427+ non_empty_transcription ( parsed. text ( ) , & response )
428428}
429429
430430fn audio_input_part ( audio : & Audio ) -> serde_json:: Value {
@@ -463,14 +463,29 @@ fn thinking_config(model: &str) -> serde_json::Value {
463463 }
464464}
465465
466- fn non_empty_transcription ( text : Option < String > ) -> Result < String > {
466+ fn non_empty_transcription ( text : Option < String > , response : & str ) -> Result < String > {
467467 let trimmed = text. unwrap_or_default ( ) . trim ( ) . to_string ( ) ;
468468 if trimmed. is_empty ( ) {
469- bail ! ( "Gemini returned an empty transcription" ) ;
469+ // Carry the payload into the message: an unexpected response shape is
470+ // otherwise indistinguishable from silence, and diagnosing it would
471+ // mean another build just to see what came back.
472+ bail ! (
473+ "Gemini returned an empty transcription. Response: {}" ,
474+ truncate_for_error( response)
475+ ) ;
470476 }
471477 Ok ( trimmed)
472478}
473479
480+ /// Trim a payload for an error message without splitting a UTF-8 character.
481+ fn truncate_for_error ( body : & str ) -> String {
482+ const LIMIT : usize = 400 ;
483+ match body. char_indices ( ) . nth ( LIMIT ) {
484+ Some ( ( end, _) ) => format ! ( "{}…" , & body[ ..end] ) ,
485+ None => body. to_string ( ) ,
486+ }
487+ }
488+
474489async fn post_json ( http : & dyn HttpClient , api_key : & str , uri : & str , body : & str ) -> Result < String > {
475490 with_retry ( || {
476491 let body = body. to_string ( ) ;
@@ -643,30 +658,44 @@ fn build_prompt(language: &str) -> String {
643658
644659// ─── Gemini response shapes ──────────────────────────────────────
645660
661+ /// The Interactions API returns the transcription as text blocks inside
662+ /// `steps[].content[]`. `output_text` is the convenience field the SDKs expose;
663+ /// it is accepted as a fallback in case the wire format carries it too.
646664#[ derive( Deserialize ) ]
647665struct InteractionsResponse {
648666 #[ serde( default ) ]
649- output : Vec < InteractionsOutput > ,
667+ steps : Vec < InteractionsStep > ,
650668 #[ serde( default ) ]
651- text : Option < String > ,
669+ output_text : Option < String > ,
652670}
653671
654672impl InteractionsResponse {
655673 fn text ( self ) -> Option < String > {
656- self . text . or_else ( || {
657- self . output
658- . into_iter ( )
659- . find_map ( |output| output. text . or ( output. transcription ) )
660- } )
674+ let from_steps = self
675+ . steps
676+ . into_iter ( )
677+ . flat_map ( |step| step. content )
678+ . filter_map ( |block| block. text )
679+ . collect :: < Vec < _ > > ( )
680+ . join ( " " ) ;
681+ if from_steps. trim ( ) . is_empty ( ) {
682+ self . output_text
683+ } else {
684+ Some ( from_steps)
685+ }
661686 }
662687}
663688
664689#[ derive( Deserialize ) ]
665- struct InteractionsOutput {
690+ struct InteractionsStep {
666691 #[ serde( default ) ]
667- text : Option < String > ,
692+ content : Vec < InteractionsContent > ,
693+ }
694+
695+ #[ derive( Deserialize ) ]
696+ struct InteractionsContent {
668697 #[ serde( default ) ]
669- transcription : Option < String > ,
698+ text : Option < String > ,
670699}
671700
672701#[ derive( Deserialize ) ]
@@ -824,27 +853,80 @@ mod tests {
824853 }
825854
826855 #[ test]
827- fn interactions_response_reads_both_shapes ( ) {
828- let flat: InteractionsResponse =
829- serde_json:: from_str ( r#"{"text":"привіт"}"# ) . expect ( "flat shape parses" ) ;
830- assert_eq ! ( flat. text( ) . as_deref( ) , Some ( "привіт" ) ) ;
831-
832- let nested: InteractionsResponse =
833- serde_json:: from_str ( r#"{"output":[{"transcription":"привіт"}]}"# )
834- . expect ( "nested shape parses" ) ;
835- assert_eq ! ( nested. text( ) . as_deref( ) , Some ( "привіт" ) ) ;
836-
837- let empty: InteractionsResponse =
838- serde_json:: from_str ( "{}" ) . expect ( "empty shape parses" ) ;
856+ fn interactions_response_reads_the_documented_shape ( ) {
857+ let response = r#"{
858+ "id": "int_abc",
859+ "status": "completed",
860+ "steps": [
861+ {
862+ "type": "model_output",
863+ "status": "done",
864+ "content": [{ "type": "text", "text": "привіт" }]
865+ }
866+ ]
867+ }"# ;
868+ let parsed: InteractionsResponse =
869+ serde_json:: from_str ( response) . expect ( "documented shape parses" ) ;
870+ assert_eq ! ( parsed. text( ) . as_deref( ) , Some ( "привіт" ) ) ;
871+ }
872+
873+ #[ test]
874+ fn interactions_response_joins_multiple_text_blocks ( ) {
875+ let response = r#"{
876+ "steps": [
877+ { "content": [{ "type": "text", "text": "перше" }] },
878+ { "content": [{ "type": "text", "text": "друге" }] }
879+ ]
880+ }"# ;
881+ let parsed: InteractionsResponse =
882+ serde_json:: from_str ( response) . expect ( "multi-step shape parses" ) ;
883+ assert_eq ! ( parsed. text( ) . as_deref( ) , Some ( "перше друге" ) ) ;
884+ }
885+
886+ #[ test]
887+ fn interactions_response_falls_back_to_output_text ( ) {
888+ let parsed: InteractionsResponse =
889+ serde_json:: from_str ( r#"{"output_text":"привіт","steps":[]}"# )
890+ . expect ( "output_text shape parses" ) ;
891+ assert_eq ! ( parsed. text( ) . as_deref( ) , Some ( "привіт" ) ) ;
892+ }
893+
894+ #[ test]
895+ fn interactions_response_yields_nothing_when_there_is_no_text ( ) {
896+ let empty: InteractionsResponse = serde_json:: from_str ( "{}" ) . expect ( "empty shape parses" ) ;
839897 assert ! ( empty. text( ) . is_none( ) ) ;
898+
899+ let no_text: InteractionsResponse =
900+ serde_json:: from_str ( r#"{"steps":[{"type":"function_call","content":[]}]}"# )
901+ . expect ( "textless step parses" ) ;
902+ assert ! ( no_text. text( ) . is_none( ) ) ;
903+ }
904+
905+ #[ test]
906+ fn empty_transcription_error_carries_the_payload ( ) {
907+ let err = non_empty_transcription ( None , r#"{"steps":[]}"# )
908+ . expect_err ( "empty transcription is an error" ) ;
909+ assert ! ( err. to_string( ) . contains( r#"{"steps":[]}"# ) ) ;
910+ }
911+
912+ #[ test]
913+ fn truncate_for_error_does_not_split_characters ( ) {
914+ let short = "привіт" ;
915+ assert_eq ! ( truncate_for_error( short) , short) ;
916+
917+ let long = "я" . repeat ( 500 ) ;
918+ let truncated = truncate_for_error ( & long) ;
919+ assert ! ( truncated. ends_with( '…' ) ) ;
920+ assert_eq ! ( truncated. chars( ) . count( ) , 401 ) ;
840921 }
841922
842923 #[ test]
843924 fn empty_transcription_is_rejected ( ) {
844- assert ! ( non_empty_transcription( Some ( " " . into( ) ) ) . is_err( ) ) ;
845- assert ! ( non_empty_transcription( None ) . is_err( ) ) ;
925+ assert ! ( non_empty_transcription( Some ( " " . into( ) ) , "{}" ) . is_err( ) ) ;
926+ assert ! ( non_empty_transcription( None , "{}" ) . is_err( ) ) ;
846927 assert_eq ! (
847- non_empty_transcription( Some ( " привіт " . into( ) ) ) . expect( "non-empty text is kept" ) ,
928+ non_empty_transcription( Some ( " привіт " . into( ) ) , "{}" )
929+ . expect( "non-empty text is kept" ) ,
848930 "привіт"
849931 ) ;
850932 }
0 commit comments