Skip to content

Commit dee1981

Browse files
committed
Update dictation.rs
1 parent d8cd5f8 commit dee1981

1 file changed

Lines changed: 111 additions & 29 deletions

File tree

‎crates/dictation/src/dictation.rs‎

Lines changed: 111 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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

430430
fn 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+
474489
async 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)]
647665
struct 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

654672
impl 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

Comments
 (0)