Skip to main content

kcode_speaker_v3_llm_protocol/
lib.rs

1use serde::Serialize;
2use serde_json::{Map, Value, json};
3use std::{collections::BTreeSet, error::Error, fmt};
4
5pub use kcode_speaker_v3_schema::{
6    FEATURE_NAMES, FeatureVector24, LocalSpeakerLabel, StructuredAnalysis, StructuredSpeaker,
7    ValidationError as AnalysisValidationError, VocalGenderPresentation,
8};
9
10pub const GEMINI_TRANSCRIPT_PROMPT_REVISION: &str = "speaker-v3-gemini-transcript-r1";
11pub const GEMINI_FEATURE_PROMPT_ONE_REVISION: &str = "speaker-v3-gemini-feature-1-r2";
12pub const GEMINI_FEATURE_PROMPT_TWO_REVISION: &str = "speaker-v3-gemini-feature-2-r2";
13pub const GEMINI_FEATURE_PROMPT_THREE_REVISION: &str = "speaker-v3-gemini-feature-3-r2";
14pub const GPT_STRUCTURING_PROMPT_REVISION: &str = "speaker-v3-gpt-structure-r2";
15pub const TERRA_SPEAKER_LABELS_PROMPT_REVISION: &str = "speaker-v3-terra-labels-r1";
16
17pub const GEMINI_FEATURE_PROMPT_REVISIONS: [&str; 3] = [
18    GEMINI_FEATURE_PROMPT_ONE_REVISION,
19    GEMINI_FEATURE_PROMPT_TWO_REVISION,
20    GEMINI_FEATURE_PROMPT_THREE_REVISION,
21];
22
23pub const GEMINI_TRANSCRIPT_PROMPT: &str = include_str!("gemini-transcript-prompt.txt");
24pub const GEMINI_FEATURE_PROMPT_ONE: &str = include_str!("gemini-feature-1-prompt.txt");
25pub const GEMINI_FEATURE_PROMPT_TWO: &str = include_str!("gemini-feature-2-prompt.txt");
26pub const GEMINI_FEATURE_PROMPT_THREE: &str = include_str!("gemini-feature-3-prompt.txt");
27pub const GPT_STRUCTURING_PROMPT: &str = include_str!("gpt-structuring-prompt.txt");
28pub const TERRA_SPEAKER_LABELS_PROMPT: &str = include_str!("terra-speaker-labels-prompt.txt");
29
30pub const RECORD_SPEAKER_LABELS_TOOL_NAME: &str = "record_speaker_labels";
31pub const RECORD_SPEAKER_LABELS_TOOL_DESCRIPTION: &str = "Record every exact local Speaker N label from the supplied Gemini transcript in first-appearance order.";
32pub const RECORD_SPEAKER_ANALYSIS_TOOL_NAME: &str = "record_speaker_analysis";
33pub const RECORD_SPEAKER_ANALYSIS_TOOL_DESCRIPTION: &str = "Record the exact transcript and complete structured 24-feature analysis for every local speaker.";
34
35pub const FEATURE_PACKETS: [FeaturePacket; 3] =
36    [FeaturePacket::One, FeaturePacket::Two, FeaturePacket::Three];
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum GeminiRequestPart<'a> {
40    Audio {
41        media_type: &'static str,
42        bytes: &'a [u8],
43    },
44    Text(String),
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub enum FeaturePacket {
49    One,
50    Two,
51    Three,
52}
53
54impl FeaturePacket {
55    pub fn index(self) -> usize {
56        match self {
57            Self::One => 0,
58            Self::Two => 1,
59            Self::Three => 2,
60        }
61    }
62
63    pub fn prompt(self) -> &'static str {
64        match self {
65            Self::One => GEMINI_FEATURE_PROMPT_ONE,
66            Self::Two => GEMINI_FEATURE_PROMPT_TWO,
67            Self::Three => GEMINI_FEATURE_PROMPT_THREE,
68        }
69    }
70
71    pub fn revision(self) -> &'static str {
72        match self {
73            Self::One => GEMINI_FEATURE_PROMPT_ONE_REVISION,
74            Self::Two => GEMINI_FEATURE_PROMPT_TWO_REVISION,
75            Self::Three => GEMINI_FEATURE_PROMPT_THREE_REVISION,
76        }
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum ProtocolError {
82    Blank(&'static str),
83    InvalidGeminiResponse(String),
84    GeminiTextCandidateCount(usize),
85    InvalidSpeakerLabelsArguments(String),
86    DuplicateSpeakerLabel(LocalSpeakerLabel),
87    InvalidFinalArguments(String),
88    InvalidStructuredAnalysis(AnalysisValidationError),
89}
90
91impl fmt::Display for ProtocolError {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::Blank(field) => write!(formatter, "{field} is blank"),
95            Self::InvalidGeminiResponse(message) => {
96                write!(formatter, "invalid Gemini response: {message}")
97            }
98            Self::GeminiTextCandidateCount(count) => {
99                write!(
100                    formatter,
101                    "Gemini returned {count} nonblank textual candidates"
102                )
103            }
104            Self::InvalidSpeakerLabelsArguments(message) => {
105                write!(formatter, "invalid speaker-label arguments: {message}")
106            }
107            Self::DuplicateSpeakerLabel(label) => {
108                write!(formatter, "duplicate speaker label: {label}")
109            }
110            Self::InvalidFinalArguments(message) => {
111                write!(formatter, "invalid final analysis arguments: {message}")
112            }
113            Self::InvalidStructuredAnalysis(error) => {
114                write!(formatter, "invalid structured analysis: {error}")
115            }
116        }
117    }
118}
119
120impl Error for ProtocolError {
121    fn source(&self) -> Option<&(dyn Error + 'static)> {
122        match self {
123            Self::InvalidStructuredAnalysis(error) => Some(error),
124            _ => None,
125        }
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ToolDefinition {
131    pub name: &'static str,
132    pub description: &'static str,
133    pub input_schema: Value,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct TerraSpeakerLabelsInput {
138    transcript: String,
139}
140
141impl TerraSpeakerLabelsInput {
142    pub fn new(transcript: String) -> Result<Self, ProtocolError> {
143        require_nonblank(&transcript, "transcript")?;
144        Ok(Self { transcript })
145    }
146
147    pub fn instruction(&self) -> &'static str {
148        TERRA_SPEAKER_LABELS_PROMPT
149    }
150
151    pub fn transcript(&self) -> &str {
152        &self.transcript
153    }
154
155    pub fn render(&self) -> String {
156        format!(
157            "{}Input:\n{}",
158            self.instruction(),
159            serde_json::to_string(self).expect("serializing strings cannot fail")
160        )
161    }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct SpeakerFeatureEvidence {
166    speaker: LocalSpeakerLabel,
167    feature_packets: [String; 3],
168}
169
170impl SpeakerFeatureEvidence {
171    pub fn new(
172        speaker: LocalSpeakerLabel,
173        packet_one: String,
174        packet_two: String,
175        packet_three: String,
176    ) -> Result<Self, ProtocolError> {
177        require_nonblank(&packet_one, "feature_packet_one")?;
178        require_nonblank(&packet_two, "feature_packet_two")?;
179        require_nonblank(&packet_three, "feature_packet_three")?;
180        Ok(Self {
181            speaker,
182            feature_packets: [packet_one, packet_two, packet_three],
183        })
184    }
185
186    pub fn speaker(&self) -> LocalSpeakerLabel {
187        self.speaker
188    }
189
190    pub fn packet(&self, packet: FeaturePacket) -> &str {
191        &self.feature_packets[packet.index()]
192    }
193
194    pub fn packets(&self) -> [&str; 3] {
195        self.feature_packets.each_ref().map(String::as_str)
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct TerraFinalInput {
201    transcript: String,
202    speakers: Vec<SpeakerFeatureEvidence>,
203}
204
205impl TerraFinalInput {
206    pub fn new(
207        transcript: String,
208        speakers: Vec<SpeakerFeatureEvidence>,
209    ) -> Result<Self, ProtocolError> {
210        require_nonblank(&transcript, "transcript")?;
211        let mut labels = BTreeSet::new();
212        for speaker in &speakers {
213            if !labels.insert(speaker.speaker()) {
214                return Err(ProtocolError::DuplicateSpeakerLabel(speaker.speaker()));
215            }
216        }
217        Ok(Self {
218            transcript,
219            speakers,
220        })
221    }
222
223    pub fn instruction(&self) -> &'static str {
224        GPT_STRUCTURING_PROMPT
225    }
226
227    pub fn transcript(&self) -> &str {
228        &self.transcript
229    }
230
231    pub fn speakers(&self) -> &[SpeakerFeatureEvidence] {
232        &self.speakers
233    }
234
235    pub fn render(&self) -> String {
236        format!(
237            "{}Input:\n{}",
238            self.instruction(),
239            serde_json::to_string(self).expect("serializing strings cannot fail")
240        )
241    }
242}
243
244pub fn gemini_transcript_request(audio: &[u8]) -> [GeminiRequestPart<'_>; 2] {
245    [
246        GeminiRequestPart::Audio {
247            media_type: "audio/ogg",
248            bytes: audio,
249        },
250        GeminiRequestPart::Text(GEMINI_TRANSCRIPT_PROMPT.to_owned()),
251    ]
252}
253
254pub fn gemini_feature_cached_prefix<'a>(
255    audio: &'a [u8],
256    transcript: &str,
257) -> [GeminiRequestPart<'a>; 2] {
258    let (shared_prefix, _) = feature_prompt_parts(FeaturePacket::One);
259    [
260        GeminiRequestPart::Audio {
261            media_type: "audio/ogg",
262            bytes: audio,
263        },
264        GeminiRequestPart::Text(format!("{shared_prefix}{transcript}")),
265    ]
266}
267
268pub fn gemini_feature_suffix(packet: FeaturePacket, target: LocalSpeakerLabel) -> String {
269    let (_, suffix) = feature_prompt_parts(packet);
270    suffix.replace("{{TARGET_SPEAKER}}", &target.to_string())
271}
272
273pub fn extract_gemini_text(response: &Value) -> Result<String, ProtocolError> {
274    let candidates = response
275        .get("candidates")
276        .and_then(Value::as_array)
277        .ok_or_else(|| ProtocolError::InvalidGeminiResponse("candidates is not an array".into()))?;
278    let mut textual_candidates = Vec::new();
279    for (candidate_index, candidate) in candidates.iter().enumerate() {
280        let parts = candidate
281            .get("content")
282            .and_then(|content| content.get("parts"))
283            .and_then(Value::as_array)
284            .ok_or_else(|| {
285                ProtocolError::InvalidGeminiResponse(format!(
286                    "candidate {candidate_index} has no content parts array"
287                ))
288            })?;
289        let mut text = String::new();
290        for (part_index, part) in parts.iter().enumerate() {
291            if let Some(value) = part.get("text") {
292                let value = value.as_str().ok_or_else(|| {
293                    ProtocolError::InvalidGeminiResponse(format!(
294                        "candidate {candidate_index} part {part_index} text is not a string"
295                    ))
296                })?;
297                text.push_str(value);
298            }
299        }
300        if !text.trim().is_empty() {
301            textual_candidates.push(text);
302        }
303    }
304    if textual_candidates.len() != 1 {
305        return Err(ProtocolError::GeminiTextCandidateCount(
306            textual_candidates.len(),
307        ));
308    }
309    Ok(textual_candidates.pop().expect("length checked"))
310}
311
312pub fn record_speaker_labels_tool() -> ToolDefinition {
313    ToolDefinition {
314        name: RECORD_SPEAKER_LABELS_TOOL_NAME,
315        description: RECORD_SPEAKER_LABELS_TOOL_DESCRIPTION,
316        input_schema: json!({
317            "type": "object",
318            "additionalProperties": false,
319            "required": ["speakers"],
320            "properties": {
321                "speakers": {
322                    "type": "array",
323                    "items": {
324                        "type": "string",
325                        "pattern": "^Speaker [1-9][0-9]*$"
326                    }
327                }
328            }
329        }),
330    }
331}
332
333pub fn decode_record_speaker_labels_arguments(
334    arguments: &Value,
335) -> Result<Vec<LocalSpeakerLabel>, ProtocolError> {
336    let object = arguments.as_object().ok_or_else(|| {
337        ProtocolError::InvalidSpeakerLabelsArguments("arguments is not an object".into())
338    })?;
339    require_exact_keys(object, &["speakers"])
340        .map_err(ProtocolError::InvalidSpeakerLabelsArguments)?;
341    let values = object
342        .get("speakers")
343        .and_then(Value::as_array)
344        .ok_or_else(|| {
345            ProtocolError::InvalidSpeakerLabelsArguments("speakers is not an array".into())
346        })?;
347    let mut labels = Vec::with_capacity(values.len());
348    let mut unique = BTreeSet::new();
349    for (index, value) in values.iter().enumerate() {
350        let raw = value.as_str().ok_or_else(|| {
351            ProtocolError::InvalidSpeakerLabelsArguments(format!("speaker {index} is not a string"))
352        })?;
353        let label = parse_canonical_label(raw).map_err(|message| {
354            ProtocolError::InvalidSpeakerLabelsArguments(format!("speaker {index}: {message}"))
355        })?;
356        if !unique.insert(label) {
357            return Err(ProtocolError::DuplicateSpeakerLabel(label));
358        }
359        labels.push(label);
360    }
361    Ok(labels)
362}
363
364pub fn record_speaker_analysis_tool() -> ToolDefinition {
365    let mut feature_properties = Map::new();
366    for name in FEATURE_NAMES {
367        feature_properties.insert(name.into(), feature_property(name));
368    }
369    ToolDefinition {
370        name: RECORD_SPEAKER_ANALYSIS_TOOL_NAME,
371        description: RECORD_SPEAKER_ANALYSIS_TOOL_DESCRIPTION,
372        input_schema: json!({
373            "type": "object",
374            "additionalProperties": false,
375            "required": ["transcript", "speakers"],
376            "properties": {
377                "transcript": {
378                    "type": "string"
379                },
380                "speakers": {
381                    "type": "array",
382                    "items": {
383                        "type": "object",
384                        "additionalProperties": false,
385                        "required": [
386                            "speaker",
387                            "language",
388                            "features",
389                            "features_usable_for_training"
390                        ],
391                        "properties": {
392                            "speaker": {
393                                "type": "string",
394                                "pattern": "^Speaker [1-9][0-9]*$"
395                            },
396                            "language": {
397                                "type": "string"
398                            },
399                            "features": {
400                                "type": "object",
401                                "additionalProperties": false,
402                                "required": FEATURE_NAMES,
403                                "properties": feature_properties
404                            },
405                            "features_usable_for_training": {
406                                "type": "boolean"
407                            }
408                        }
409                    }
410                }
411            }
412        }),
413    }
414}
415
416pub fn decode_record_speaker_analysis_arguments(
417    arguments: &Value,
418) -> Result<StructuredAnalysis, ProtocolError> {
419    validate_final_shape(arguments)?;
420    let analysis: StructuredAnalysis = serde_json::from_value(arguments.clone())
421        .map_err(|error| ProtocolError::InvalidFinalArguments(error.to_string()))?;
422    analysis
423        .validate()
424        .map_err(ProtocolError::InvalidStructuredAnalysis)?;
425    Ok(analysis)
426}
427
428fn require_nonblank(value: &str, field: &'static str) -> Result<(), ProtocolError> {
429    if value.trim().is_empty() {
430        return Err(ProtocolError::Blank(field));
431    }
432    Ok(())
433}
434
435fn feature_prompt_parts(packet: FeaturePacket) -> (&'static str, &'static str) {
436    packet
437        .prompt()
438        .split_once("{{TRANSCRIPT}}")
439        .expect("frozen feature prompt contains transcript placeholder")
440}
441
442fn parse_canonical_label(value: &str) -> Result<LocalSpeakerLabel, String> {
443    let label = value
444        .parse::<LocalSpeakerLabel>()
445        .map_err(|error| error.to_string())?;
446    if label.to_string() != value {
447        return Err(format!("noncanonical speaker label: {value}"));
448    }
449    Ok(label)
450}
451
452fn require_exact_keys(object: &Map<String, Value>, expected: &[&str]) -> Result<(), String> {
453    if object.len() != expected.len() || expected.iter().any(|key| !object.contains_key(*key)) {
454        let found = object.keys().cloned().collect::<Vec<_>>().join(", ");
455        return Err(format!(
456            "object keys must be exactly [{}], found [{found}]",
457            expected.join(", ")
458        ));
459    }
460    Ok(())
461}
462
463fn feature_property(name: &str) -> Value {
464    match name {
465        "dominant_rhotic_realization" | "dominant_lateral_realization" => {
466            json!({ "type": ["string", "null"] })
467        }
468        "vocal_gender_presentation" => json!({
469            "type": ["string", "null"],
470            "enum": [
471                "strongly_feminine",
472                "feminine",
473                "androgynous",
474                "masculine",
475                "strongly_masculine",
476                null
477            ]
478        }),
479        _ => json!({ "type": ["number", "null"] }),
480    }
481}
482
483fn validate_final_shape(arguments: &Value) -> Result<(), ProtocolError> {
484    let object = arguments
485        .as_object()
486        .ok_or_else(|| ProtocolError::InvalidFinalArguments("arguments is not an object".into()))?;
487    require_exact_keys(object, &["transcript", "speakers"])
488        .map_err(ProtocolError::InvalidFinalArguments)?;
489    let speakers = object
490        .get("speakers")
491        .and_then(Value::as_array)
492        .ok_or_else(|| ProtocolError::InvalidFinalArguments("speakers is not an array".into()))?;
493    for (speaker_index, speaker) in speakers.iter().enumerate() {
494        let speaker_object = speaker.as_object().ok_or_else(|| {
495            ProtocolError::InvalidFinalArguments(format!(
496                "speaker {speaker_index} is not an object"
497            ))
498        })?;
499        require_exact_keys(
500            speaker_object,
501            &[
502                "speaker",
503                "language",
504                "features",
505                "features_usable_for_training",
506            ],
507        )
508        .map_err(|message| {
509            ProtocolError::InvalidFinalArguments(format!("speaker {speaker_index}: {message}"))
510        })?;
511        let raw_label = speaker_object
512            .get("speaker")
513            .and_then(Value::as_str)
514            .ok_or_else(|| {
515                ProtocolError::InvalidFinalArguments(format!(
516                    "speaker {speaker_index} label is not a string"
517                ))
518            })?;
519        parse_canonical_label(raw_label).map_err(|message| {
520            ProtocolError::InvalidFinalArguments(format!("speaker {speaker_index}: {message}"))
521        })?;
522        let features = speaker_object
523            .get("features")
524            .and_then(Value::as_object)
525            .ok_or_else(|| {
526                ProtocolError::InvalidFinalArguments(format!(
527                    "speaker {speaker_index} features is not an object"
528                ))
529            })?;
530        require_exact_keys(features, &FEATURE_NAMES).map_err(|message| {
531            ProtocolError::InvalidFinalArguments(format!(
532                "speaker {speaker_index} features: {message}"
533            ))
534        })?;
535    }
536    Ok(())
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use std::time::Instant;
543
544    fn label(number: u32) -> LocalSpeakerLabel {
545        LocalSpeakerLabel::new(number).unwrap()
546    }
547
548    fn candidate(parts: Vec<Value>) -> Value {
549        json!({ "content": { "parts": parts } })
550    }
551
552    fn null_features() -> Map<String, Value> {
553        FEATURE_NAMES
554            .into_iter()
555            .map(|name| (name.into(), Value::Null))
556            .collect()
557    }
558
559    fn final_arguments() -> Value {
560        json!({
561            "transcript": "[high] Speaker 1: exact words",
562            "speakers": [{
563                "speaker": "Speaker 1",
564                "language": "English",
565                "features": null_features(),
566                "features_usable_for_training": true
567            }]
568        })
569    }
570
571    #[test]
572    fn revisions_and_frozen_prompts_cover_the_workflow() {
573        assert_eq!(
574            GEMINI_FEATURE_PROMPT_REVISIONS,
575            [
576                "speaker-v3-gemini-feature-1-r2",
577                "speaker-v3-gemini-feature-2-r2",
578                "speaker-v3-gemini-feature-3-r2",
579            ]
580        );
581        assert_eq!(
582            TERRA_SPEAKER_LABELS_PROMPT_REVISION,
583            "speaker-v3-terra-labels-r1"
584        );
585        assert!(GEMINI_TRANSCRIPT_PROMPT.contains("[high] Speaker N:"));
586        assert!(GPT_STRUCTURING_PROMPT.contains("record_speaker_analysis"));
587        assert!(TERRA_SPEAKER_LABELS_PROMPT.contains("record_speaker_labels"));
588        for (packet, names) in FEATURE_PACKETS.into_iter().zip([
589            &FEATURE_NAMES[..8],
590            &FEATURE_NAMES[8..16],
591            &FEATURE_NAMES[16..],
592        ]) {
593            assert!(packet.prompt().contains("{{TRANSCRIPT}}"));
594            assert!(packet.prompt().contains("{{TARGET_SPEAKER}}"));
595            for name in names {
596                assert!(packet.prompt().contains(name));
597            }
598        }
599    }
600
601    #[test]
602    fn gemini_requests_keep_audio_prefix_and_target_last() {
603        let audio = b"OggS bytes";
604        let transcript_request = gemini_transcript_request(audio);
605        assert_eq!(
606            transcript_request[0],
607            GeminiRequestPart::Audio {
608                media_type: "audio/ogg",
609                bytes: audio,
610            }
611        );
612        assert_eq!(
613            transcript_request[1],
614            GeminiRequestPart::Text(GEMINI_TRANSCRIPT_PROMPT.into())
615        );
616
617        let transcript = "literal {{TARGET_SPEAKER}}\ntranscript";
618        let prefix = gemini_feature_cached_prefix(audio, transcript);
619        assert_eq!(
620            prefix[0],
621            GeminiRequestPart::Audio {
622                media_type: "audio/ogg",
623                bytes: audio,
624            }
625        );
626        let GeminiRequestPart::Text(prefix_text) = &prefix[1] else {
627            panic!()
628        };
629        let (shared, _) = feature_prompt_parts(FeaturePacket::One);
630        assert_eq!(prefix_text, &format!("{shared}{transcript}"));
631
632        for packet in FEATURE_PACKETS {
633            let suffix = gemini_feature_suffix(packet, label(12));
634            assert!(!suffix.contains("{{TARGET_SPEAKER}}"));
635            assert!(suffix.ends_with("Speaker 12\n"));
636            assert_eq!(
637                packet.revision(),
638                GEMINI_FEATURE_PROMPT_REVISIONS[packet.index()]
639            );
640            assert_eq!(
641                feature_prompt_parts(packet).0,
642                feature_prompt_parts(FeaturePacket::One).0
643            );
644        }
645    }
646
647    #[test]
648    fn gemini_extraction_preserves_one_textual_candidate() {
649        let response = json!({
650            "candidates": [
651                candidate(vec![
652                    json!({"text": "a\n"}),
653                    json!({"inlineData": {}}),
654                    json!({"text": " b"})
655                ]),
656                candidate(vec![json!({"text": "   "})])
657            ]
658        });
659        assert_eq!(extract_gemini_text(&response).unwrap(), "a\n b");
660
661        let ambiguous = json!({
662            "candidates": [
663                candidate(vec![json!({"text": "one"})]),
664                candidate(vec![json!({"text": "two"})])
665            ]
666        });
667        assert_eq!(
668            extract_gemini_text(&ambiguous),
669            Err(ProtocolError::GeminiTextCandidateCount(2))
670        );
671        assert!(matches!(
672            extract_gemini_text(&json!({})),
673            Err(ProtocolError::InvalidGeminiResponse(_))
674        ));
675    }
676
677    #[test]
678    fn label_tool_and_decode_are_strict_ordered_and_unbounded() {
679        let tool = record_speaker_labels_tool();
680        assert_eq!(tool.name, "record_speaker_labels");
681        assert_eq!(tool.input_schema["additionalProperties"], false);
682        assert!(
683            tool.input_schema["properties"]["speakers"]
684                .get("maxItems")
685                .is_none()
686        );
687
688        let arguments = json!({
689            "speakers": (1..=1000)
690                .map(|number| format!("Speaker {number}"))
691                .collect::<Vec<_>>()
692        });
693        let decoded = decode_record_speaker_labels_arguments(&arguments).unwrap();
694        assert_eq!(decoded.len(), 1000);
695        assert_eq!(decoded[0], label(1));
696        assert_eq!(decoded[999], label(1000));
697
698        assert!(matches!(
699            decode_record_speaker_labels_arguments(
700                &json!({"speakers": ["Speaker 1", "Speaker 1"]})
701            ),
702            Err(ProtocolError::DuplicateSpeakerLabel(_))
703        ));
704        for invalid in ["Unknown", "Speaker 0", "Speaker 01", "speaker 1"] {
705            assert!(
706                decode_record_speaker_labels_arguments(&json!({"speakers": [invalid]})).is_err()
707            );
708        }
709        assert!(
710            decode_record_speaker_labels_arguments(&json!({"speakers": [], "extra": true}))
711                .is_err()
712        );
713
714        let input = TerraSpeakerLabelsInput::new("x\n\"Input:\\n\" {{raw}}".into()).unwrap();
715        let rendered = input.render();
716        let serialized = rendered
717            .strip_prefix(TERRA_SPEAKER_LABELS_PROMPT)
718            .unwrap()
719            .strip_prefix("Input:\n")
720            .unwrap();
721        let decoded_input: Value = serde_json::from_str(serialized).unwrap();
722        assert_eq!(decoded_input["transcript"], input.transcript());
723    }
724
725    #[test]
726    fn evidence_and_final_input_preserve_adversarial_text_and_order() {
727        let first = SpeakerFeatureEvidence::new(
728            label(2),
729            "one\n\"x\"".into(),
730            "two {{raw}}".into(),
731            "three Input:\n".into(),
732        )
733        .unwrap();
734        assert_eq!(
735            first.packets(),
736            ["one\n\"x\"", "two {{raw}}", "three Input:\n"]
737        );
738        let second =
739            SpeakerFeatureEvidence::new(label(1), "four".into(), "five".into(), "six".into())
740                .unwrap();
741        let input = TerraFinalInput::new(
742            "transcript\n\"quoted\"".into(),
743            vec![first.clone(), second.clone()],
744        )
745        .unwrap();
746        assert_eq!(input.speakers()[0].speaker(), label(2));
747        assert_eq!(input.speakers()[1].speaker(), label(1));
748        let rendered = input.render();
749        let serialized = rendered
750            .strip_prefix(GPT_STRUCTURING_PROMPT)
751            .unwrap()
752            .strip_prefix("Input:\n")
753            .unwrap();
754        let decoded: Value = serde_json::from_str(serialized).unwrap();
755        assert_eq!(decoded["transcript"], input.transcript());
756        assert_eq!(decoded["speakers"][0]["speaker"], "Speaker 2");
757        assert_eq!(
758            decoded["speakers"][0]["feature_packets"][0],
759            first.packet(FeaturePacket::One)
760        );
761        assert_eq!(
762            decoded["speakers"][1]["feature_packets"][2],
763            second.packet(FeaturePacket::Three)
764        );
765        assert!(TerraFinalInput::new("x".into(), vec![first.clone(), first]).is_err());
766    }
767
768    #[test]
769    fn final_tool_schema_is_closed_complete_and_nullable() {
770        let tool = record_speaker_analysis_tool();
771        assert_eq!(tool.name, "record_speaker_analysis");
772        assert_eq!(tool.input_schema["additionalProperties"], false);
773        let speaker = &tool.input_schema["properties"]["speakers"]["items"];
774        assert_eq!(speaker["additionalProperties"], false);
775        let features = &speaker["properties"]["features"];
776        assert_eq!(features["additionalProperties"], false);
777        assert_eq!(features["required"].as_array().unwrap().len(), 24);
778        assert_eq!(features["properties"].as_object().unwrap().len(), 24);
779        for name in FEATURE_NAMES {
780            assert!(features["properties"][name].to_string().contains("null"));
781        }
782    }
783
784    #[test]
785    fn final_decode_requires_complete_closed_valid_analysis() {
786        let arguments = final_arguments();
787        let analysis = decode_record_speaker_analysis_arguments(&arguments).unwrap();
788        assert_eq!(analysis.transcript, "[high] Speaker 1: exact words");
789        assert_eq!(analysis.speakers.len(), 1);
790        assert!(analysis.speakers[0].features_usable_for_training);
791
792        let mut extra = arguments.clone();
793        extra
794            .as_object_mut()
795            .unwrap()
796            .insert("extra".into(), json!(true));
797        assert!(decode_record_speaker_analysis_arguments(&extra).is_err());
798
799        let mut missing = arguments.clone();
800        missing["speakers"][0]["features"]
801            .as_object_mut()
802            .unwrap()
803            .remove("median_f0_hz");
804        assert!(decode_record_speaker_analysis_arguments(&missing).is_err());
805
806        let mut nested_extra = arguments.clone();
807        nested_extra["speakers"][0]["features"]
808            .as_object_mut()
809            .unwrap()
810            .insert("extra".into(), Value::Null);
811        assert!(decode_record_speaker_analysis_arguments(&nested_extra).is_err());
812
813        let mut noncanonical = arguments;
814        noncanonical["speakers"][0]["speaker"] = json!("Speaker 01");
815        assert!(decode_record_speaker_analysis_arguments(&noncanonical).is_err());
816    }
817
818    #[test]
819    fn reference_scale_canary_completes_local_work() {
820        let started = Instant::now();
821        let text = "x".repeat(1_048_576);
822        let response = json!({
823            "candidates": [
824                candidate(vec![json!({"text": text})])
825            ]
826        });
827        assert_eq!(extract_gemini_text(&response).unwrap().len(), 1_048_576);
828
829        let arguments = json!({
830            "speakers": (1..=1000)
831                .map(|number| format!("Speaker {number}"))
832                .collect::<Vec<_>>()
833        });
834        assert_eq!(
835            decode_record_speaker_labels_arguments(&arguments)
836                .unwrap()
837                .len(),
838            1000
839        );
840
841        let speakers = (1..=1000)
842            .map(|number| {
843                SpeakerFeatureEvidence::new(
844                    label(number),
845                    "a".repeat(1024),
846                    "b".repeat(1024),
847                    "c".repeat(1024),
848                )
849                .unwrap()
850            })
851            .collect();
852        let input = TerraFinalInput::new("x".repeat(1_048_576), speakers).unwrap();
853        assert!(input.render().len() > 4_000_000);
854        assert!(started.elapsed().as_secs() < 10);
855    }
856}