Skip to main content

flowcat_core/
transcript.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Transcript collection.
4//!
5//! Accumulates the turn-by-turn transcript from the realtime model's
6//! input/output transcription events and renders it for upload as a transcript
7//! artifact (see DESIGN.md "Audio path" / finalize).
8
9use serde::{Deserialize, Serialize};
10
11/// Who produced a transcript line.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum Speaker {
14    /// The human caller (input transcription).
15    User,
16    /// The bot / model (output transcription).
17    Bot,
18}
19
20/// A single transcript line.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct TranscriptLine {
23    /// Who spoke.
24    pub speaker: Speaker,
25    /// The (possibly incrementally-assembled) text of the line.
26    pub text: String,
27}
28
29/// Accumulates transcript lines over the life of a call.
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct Transcript {
32    /// The ordered transcript lines.
33    pub lines: Vec<TranscriptLine>,
34}
35
36impl Transcript {
37    /// Create an empty transcript.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Append (or merge into) a user line from an input-transcription delta.
43    ///
44    /// The realtime model streams transcription incrementally; consecutive
45    /// deltas from the *same* speaker are appended into the current line so a
46    /// rendered turn reads as one utterance. A speaker switch starts a new line.
47    pub fn push_user(&mut self, text: &str) {
48        self.push(Speaker::User, text);
49    }
50
51    /// Append (or merge into) a bot line from an output-transcription delta.
52    pub fn push_bot(&mut self, text: &str) {
53        self.push(Speaker::Bot, text);
54    }
55
56    /// Shared delta-merge: extend the trailing line if the speaker matches,
57    /// otherwise begin a fresh line. Empty deltas are dropped.
58    fn push(&mut self, speaker: Speaker, text: &str) {
59        if text.is_empty() {
60            return;
61        }
62        match self.lines.last_mut() {
63            Some(last) if last.speaker == speaker => last.text.push_str(text),
64            _ => self.lines.push(TranscriptLine {
65                speaker,
66                text: text.to_owned(),
67            }),
68        }
69    }
70
71    /// Number of user turns (merged user lines) — reported as
72    /// `usage_metrics.user_turns` for the Run-detail "User Turns" metric.
73    pub fn user_turns(&self) -> u64 {
74        self.lines
75            .iter()
76            .filter(|l| l.speaker == Speaker::User)
77            .count() as u64
78    }
79
80    /// Number of bot turns (merged bot lines) — reported as
81    /// `usage_metrics.bot_turns`. (Transition markers ride on a bot line, so this
82    /// is the count of bot utterances including any folded transition note.)
83    pub fn bot_turns(&self) -> u64 {
84        self.lines
85            .iter()
86            .filter(|l| l.speaker == Speaker::Bot)
87            .count() as u64
88    }
89
90    /// Render the transcript as a JSON array of `{role, text}` turns.
91    ///
92    /// `role` is the lowercase speaker (`"user"`/`"bot"`). Never panics —
93    /// serialization of this owned shape cannot fail, but on the off chance it
94    /// did we fall back to an empty array so a finalize can still proceed.
95    pub fn render(&self) -> Vec<u8> {
96        let turns: Vec<Turn> = self
97            .lines
98            .iter()
99            .map(|l| Turn {
100                role: match l.speaker {
101                    Speaker::User => "user",
102                    Speaker::Bot => "bot",
103                },
104                text: &l.text,
105            })
106            .collect();
107        serde_json::to_vec(&turns).unwrap_or_else(|_| b"[]".to_vec())
108    }
109}
110
111/// A rendered transcript turn: `{role, text}` (the JSON artifact shape).
112#[derive(Debug, Serialize)]
113struct Turn<'a> {
114    /// Lowercase speaker role: `"user"` or `"bot"`.
115    role: &'a str,
116    /// The full text of the turn.
117    text: &'a str,
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use serde_json::Value;
124
125    #[test]
126    fn empty_transcript_renders_empty_json_array() {
127        let t = Transcript::new();
128        assert_eq!(t.render(), b"[]".to_vec());
129    }
130
131    #[test]
132    fn consecutive_same_speaker_deltas_merge_into_one_line() {
133        let mut t = Transcript::new();
134        t.push_user("hel");
135        t.push_user("lo ");
136        t.push_user("there");
137        assert_eq!(t.lines.len(), 1);
138        assert_eq!(t.lines[0].speaker, Speaker::User);
139        assert_eq!(t.lines[0].text, "hello there");
140    }
141
142    #[test]
143    fn speaker_switch_starts_a_new_line() {
144        let mut t = Transcript::new();
145        t.push_user("hi");
146        t.push_bot("hello, how can I help?");
147        t.push_user("a question");
148        assert_eq!(t.lines.len(), 3);
149        assert_eq!(t.lines[0].speaker, Speaker::User);
150        assert_eq!(t.lines[1].speaker, Speaker::Bot);
151        assert_eq!(t.lines[2].speaker, Speaker::User);
152    }
153
154    #[test]
155    fn empty_deltas_are_dropped() {
156        let mut t = Transcript::new();
157        t.push_user("");
158        t.push_bot("");
159        assert!(t.lines.is_empty());
160        // An empty delta between real deltas must not split a line.
161        t.push_user("ab");
162        t.push_user("");
163        t.push_user("cd");
164        assert_eq!(t.lines.len(), 1);
165        assert_eq!(t.lines[0].text, "abcd");
166    }
167
168    #[test]
169    fn turn_counts_tally_lines_per_speaker() {
170        let mut t = Transcript::new();
171        t.push_bot("hello, how can I help?");
172        t.push_user("book an appointment");
173        t.push_bot("sure, what day?");
174        t.push_user("tuesday"); // merged deltas count as ONE turn
175        t.push_user(" afternoon");
176        assert_eq!(t.user_turns(), 2, "two user lines");
177        assert_eq!(t.bot_turns(), 2, "two bot lines");
178        let empty = Transcript::new();
179        assert_eq!(empty.user_turns(), 0);
180        assert_eq!(empty.bot_turns(), 0);
181    }
182
183    #[test]
184    fn render_is_role_text_json_array() {
185        let mut t = Transcript::new();
186        t.push_user("hi");
187        t.push_bot("hello");
188        let json: Value = serde_json::from_slice(&t.render()).unwrap();
189        assert_eq!(
190            json,
191            serde_json::json!([
192                { "role": "user", "text": "hi" },
193                { "role": "bot", "text": "hello" },
194            ])
195        );
196    }
197}