Skip to main content

kcode_speaker_v3_gemini_protocol/
lib.rs

1use kcode_speaker_v3_schema::LocalSpeakerLabel;
2use serde_json::Value;
3use std::{error::Error, fmt};
4
5pub const GEMINI_TRANSCRIPT_PROMPT_REVISION: &str = "speaker-v3-gemini-transcript-r1";
6pub const GEMINI_FEATURE_PROMPT_ONE_REVISION: &str = "speaker-v3-gemini-feature-1-r2";
7pub const GEMINI_FEATURE_PROMPT_TWO_REVISION: &str = "speaker-v3-gemini-feature-2-r2";
8pub const GEMINI_FEATURE_PROMPT_THREE_REVISION: &str = "speaker-v3-gemini-feature-3-r2";
9
10pub const GEMINI_FEATURE_PROMPT_REVISIONS: [&str; 3] = [
11    GEMINI_FEATURE_PROMPT_ONE_REVISION,
12    GEMINI_FEATURE_PROMPT_TWO_REVISION,
13    GEMINI_FEATURE_PROMPT_THREE_REVISION,
14];
15
16pub const GEMINI_TRANSCRIPT_PROMPT: &str = include_str!("gemini-transcript-prompt.txt");
17pub const GEMINI_FEATURE_PROMPT_ONE: &str = include_str!("gemini-feature-1-prompt.txt");
18pub const GEMINI_FEATURE_PROMPT_TWO: &str = include_str!("gemini-feature-2-prompt.txt");
19pub const GEMINI_FEATURE_PROMPT_THREE: &str = include_str!("gemini-feature-3-prompt.txt");
20
21pub const FEATURE_PACKETS: [FeaturePacket; 3] =
22    [FeaturePacket::One, FeaturePacket::Two, FeaturePacket::Three];
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum GeminiRequestPart<'a> {
26    Audio {
27        media_type: &'static str,
28        bytes: &'a [u8],
29    },
30    Text(String),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub enum FeaturePacket {
35    One,
36    Two,
37    Three,
38}
39
40impl FeaturePacket {
41    pub fn index(self) -> usize {
42        match self {
43            Self::One => 0,
44            Self::Two => 1,
45            Self::Three => 2,
46        }
47    }
48
49    pub fn prompt(self) -> &'static str {
50        match self {
51            Self::One => GEMINI_FEATURE_PROMPT_ONE,
52            Self::Two => GEMINI_FEATURE_PROMPT_TWO,
53            Self::Three => GEMINI_FEATURE_PROMPT_THREE,
54        }
55    }
56
57    pub fn revision(self) -> &'static str {
58        match self {
59            Self::One => GEMINI_FEATURE_PROMPT_ONE_REVISION,
60            Self::Two => GEMINI_FEATURE_PROMPT_TWO_REVISION,
61            Self::Three => GEMINI_FEATURE_PROMPT_THREE_REVISION,
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum GeminiProtocolError {
68    InvalidResponse(String),
69    TextCandidateCount(usize),
70}
71
72impl fmt::Display for GeminiProtocolError {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::InvalidResponse(message) => {
76                write!(formatter, "invalid Gemini response: {message}")
77            }
78            Self::TextCandidateCount(count) => {
79                write!(
80                    formatter,
81                    "Gemini returned {count} nonblank textual candidates"
82                )
83            }
84        }
85    }
86}
87
88impl Error for GeminiProtocolError {}
89
90pub fn gemini_transcript_request(audio: &[u8]) -> [GeminiRequestPart<'_>; 2] {
91    [
92        GeminiRequestPart::Audio {
93            media_type: "audio/ogg",
94            bytes: audio,
95        },
96        GeminiRequestPart::Text(GEMINI_TRANSCRIPT_PROMPT.to_owned()),
97    ]
98}
99
100pub fn gemini_feature_cached_prefix<'a>(
101    audio: &'a [u8],
102    transcript: &str,
103) -> [GeminiRequestPart<'a>; 2] {
104    let (shared_prefix, _) = feature_prompt_parts(FeaturePacket::One);
105    [
106        GeminiRequestPart::Audio {
107            media_type: "audio/ogg",
108            bytes: audio,
109        },
110        GeminiRequestPart::Text(format!("{shared_prefix}{transcript}")),
111    ]
112}
113
114pub fn gemini_feature_suffix(packet: FeaturePacket, target: LocalSpeakerLabel) -> String {
115    let (_, suffix) = feature_prompt_parts(packet);
116    suffix.replace("{{TARGET_SPEAKER}}", &target.to_string())
117}
118
119pub fn extract_gemini_text(response: &Value) -> Result<String, GeminiProtocolError> {
120    let candidates = response
121        .get("candidates")
122        .and_then(Value::as_array)
123        .ok_or_else(|| GeminiProtocolError::InvalidResponse("candidates is not an array".into()))?;
124    let mut textual_candidates = Vec::new();
125    for (candidate_index, candidate) in candidates.iter().enumerate() {
126        let parts = candidate
127            .get("content")
128            .and_then(|content| content.get("parts"))
129            .and_then(Value::as_array)
130            .ok_or_else(|| {
131                GeminiProtocolError::InvalidResponse(format!(
132                    "candidate {candidate_index} has no content parts array"
133                ))
134            })?;
135        let mut text = String::new();
136        for (part_index, part) in parts.iter().enumerate() {
137            if let Some(value) = part.get("text") {
138                let value = value.as_str().ok_or_else(|| {
139                    GeminiProtocolError::InvalidResponse(format!(
140                        "candidate {candidate_index} part {part_index} text is not a string"
141                    ))
142                })?;
143                text.push_str(value);
144            }
145        }
146        if !text.trim().is_empty() {
147            textual_candidates.push(text);
148        }
149    }
150    if textual_candidates.len() != 1 {
151        return Err(GeminiProtocolError::TextCandidateCount(
152            textual_candidates.len(),
153        ));
154    }
155    Ok(textual_candidates.pop().expect("length checked"))
156}
157
158fn feature_prompt_parts(packet: FeaturePacket) -> (&'static str, &'static str) {
159    packet
160        .prompt()
161        .split_once("{{TRANSCRIPT}}")
162        .expect("frozen feature prompt contains transcript placeholder")
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use kcode_speaker_v3_schema::FEATURE_NAMES;
169    use serde_json::json;
170    use std::time::Instant;
171
172    const EXPECTED_PROMPT_FINGERPRINTS: [u64; 4] = [
173        15056256181481631076,
174        14503216141444052946,
175        5759944296588955155,
176        17635457290013820104,
177    ];
178
179    fn label(number: u32) -> LocalSpeakerLabel {
180        LocalSpeakerLabel::new(number).unwrap()
181    }
182
183    fn candidate(parts: Vec<Value>) -> Value {
184        json!({ "content": { "parts": parts } })
185    }
186
187    fn fingerprint(value: &str) -> u64 {
188        value.bytes().fold(0xcbf29ce484222325, |hash, byte| {
189            (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
190        })
191    }
192
193    #[test]
194    fn prompt_bytes_revisions_and_feature_coverage_are_frozen() {
195        let actual = [
196            fingerprint(GEMINI_TRANSCRIPT_PROMPT),
197            fingerprint(GEMINI_FEATURE_PROMPT_ONE),
198            fingerprint(GEMINI_FEATURE_PROMPT_TWO),
199            fingerprint(GEMINI_FEATURE_PROMPT_THREE),
200        ];
201        assert_eq!(actual, EXPECTED_PROMPT_FINGERPRINTS);
202        assert_eq!(
203            GEMINI_FEATURE_PROMPT_REVISIONS,
204            [
205                "speaker-v3-gemini-feature-1-r2",
206                "speaker-v3-gemini-feature-2-r2",
207                "speaker-v3-gemini-feature-3-r2",
208            ]
209        );
210        assert!(GEMINI_TRANSCRIPT_PROMPT.contains("[high] Speaker N:"));
211        for (packet, names) in FEATURE_PACKETS.into_iter().zip([
212            &FEATURE_NAMES[..8],
213            &FEATURE_NAMES[8..16],
214            &FEATURE_NAMES[16..],
215        ]) {
216            assert!(packet.prompt().contains("{{TRANSCRIPT}}"));
217            assert!(packet.prompt().contains("{{TARGET_SPEAKER}}"));
218            for name in names {
219                assert!(packet.prompt().contains(name));
220            }
221        }
222    }
223
224    #[test]
225    fn requests_keep_audio_prefix_shared_text_and_target_last() {
226        let audio = b"OggS bytes";
227        assert_eq!(
228            gemini_transcript_request(audio),
229            [
230                GeminiRequestPart::Audio {
231                    media_type: "audio/ogg",
232                    bytes: audio,
233                },
234                GeminiRequestPart::Text(GEMINI_TRANSCRIPT_PROMPT.into()),
235            ]
236        );
237        let transcript = "literal {{TARGET_SPEAKER}}\ntranscript";
238        let prefix = gemini_feature_cached_prefix(audio, transcript);
239        assert_eq!(
240            prefix[0],
241            GeminiRequestPart::Audio {
242                media_type: "audio/ogg",
243                bytes: audio,
244            }
245        );
246        let GeminiRequestPart::Text(prefix_text) = &prefix[1] else {
247            panic!()
248        };
249        let (shared, _) = feature_prompt_parts(FeaturePacket::One);
250        assert_eq!(prefix_text, &format!("{shared}{transcript}"));
251        for packet in FEATURE_PACKETS {
252            let suffix = gemini_feature_suffix(packet, label(12));
253            assert!(!suffix.contains("{{TARGET_SPEAKER}}"));
254            assert!(suffix.ends_with("Speaker 12\n"));
255            assert_eq!(
256                packet.revision(),
257                GEMINI_FEATURE_PROMPT_REVISIONS[packet.index()]
258            );
259            assert_eq!(
260                feature_prompt_parts(packet).0,
261                feature_prompt_parts(FeaturePacket::One).0
262            );
263        }
264    }
265
266    #[test]
267    fn extraction_preserves_one_textual_candidate() {
268        let response = serde_json::json!({
269            "candidates": [
270                candidate(vec![
271                    serde_json::json!({"text": "a\n"}),
272                    serde_json::json!({"inlineData": {}}),
273                    serde_json::json!({"text": " b"})
274                ]),
275                candidate(vec![serde_json::json!({"text": "   "})])
276            ]
277        });
278        assert_eq!(extract_gemini_text(&response).unwrap(), "a\n b");
279        let ambiguous = serde_json::json!({
280            "candidates": [
281                candidate(vec![serde_json::json!({"text": "one"})]),
282                candidate(vec![serde_json::json!({"text": "two"})])
283            ]
284        });
285        assert_eq!(
286            extract_gemini_text(&ambiguous),
287            Err(GeminiProtocolError::TextCandidateCount(2))
288        );
289        assert_eq!(
290            extract_gemini_text(&serde_json::json!({})),
291            Err(GeminiProtocolError::InvalidResponse(
292                "candidates is not an array".into()
293            ))
294        );
295    }
296
297    #[test]
298    fn one_mebibyte_extraction_completes_within_reference_envelope() {
299        let started = Instant::now();
300        let text = "x".repeat(1_048_576);
301        let response = serde_json::json!({
302            "candidates": [candidate(vec![serde_json::json!({"text": text})])]
303        });
304        assert_eq!(extract_gemini_text(&response).unwrap().len(), 1_048_576);
305        assert!(started.elapsed().as_secs() < 10);
306    }
307}