Skip to main content

gemini_adk_rs/flow/
verbatim.rs

1//! Verbatim stages: text the model must say word for word, verified.
2//!
3//! A regulated call has lines that must be read exactly: a disclosure, a
4//! consent statement, the terms of a payment. An instruction to "read this
5//! verbatim" is a request, not a guarantee. A verbatim stage turns it into a
6//! guarantee at the governance level.
7//!
8//! - While the stage is active, the stack publishes the required text under
9//!   [`VERBATIM_KEY`].
10//! - At the end of each model turn, the control lane looks for the text in
11//!   the model's output transcript ([`contained_similarity`]) and writes the
12//!   verdict to [`verbatim_flag`]`(step)`. The text may be surrounded by
13//!   other words in the same turn, such as a greeting before a disclosure.
14//!   It also emits `LiveEvent::VerbatimChecked`.
15//! - The stage completes only once the flag is true. A paraphrase keeps the
16//!   conversation in the stage, where the posture asks for the exact text
17//!   again.
18//!
19//! The check needs output transcription, since it reads what was actually
20//! said. Without a transcript nothing is verified, and the stage does not
21//! complete.
22
23use serde::{Deserialize, Serialize};
24
25use crate::state::State;
26
27/// The state key under which the active verbatim requirement is published.
28pub const VERBATIM_KEY: &str = "session:verbatim";
29
30/// How close (0–1, word level) the spoken text must be to the required text.
31/// Tolerates a transcription slip or two in a long passage, not a paraphrase.
32pub const VERBATIM_MIN_SIMILARITY: f64 = 0.9;
33
34/// The state key holding whether `step`'s text was said verbatim.
35pub fn verbatim_flag(step: &str) -> String {
36    format!("verbatim:{step}")
37}
38
39/// The published requirement of the active verbatim stage.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct VerbatimRequirement {
42    /// The stage that requires it.
43    pub step: String,
44    /// The text to be said.
45    pub text: String,
46}
47
48/// The outcome of one verbatim check.
49#[derive(Debug, Clone, PartialEq)]
50pub struct VerbatimVerdict {
51    /// The stage checked.
52    pub step: String,
53    /// Word-level similarity of what was said to what was required.
54    pub similarity: f64,
55    /// Whether it meets [`VERBATIM_MIN_SIMILARITY`].
56    pub passed: bool,
57}
58
59/// Word-level similarity of `heard` to `expected`, from 0 to 1: one minus
60/// the word edit distance over the length of the longer text, after
61/// lowercasing and dropping punctuation.
62pub fn similarity(expected: &str, heard: &str) -> f64 {
63    let words = |s: &str| -> Vec<String> {
64        s.split_whitespace()
65            .map(|w| {
66                w.chars()
67                    .filter(|c| c.is_alphanumeric())
68                    .flat_map(char::to_lowercase)
69                    .collect::<String>()
70            })
71            .filter(|w| !w.is_empty())
72            .collect()
73    };
74    let (a, b) = (words(expected), words(heard));
75    let longest = a.len().max(b.len());
76    if longest == 0 {
77        return 1.0;
78    }
79    // Levenshtein over words, one row at a time.
80    let mut prev: Vec<usize> = (0..=b.len()).collect();
81    for (i, wa) in a.iter().enumerate() {
82        let mut row = vec![i + 1; b.len() + 1];
83        for (j, wb) in b.iter().enumerate() {
84            let cost = usize::from(wa != wb);
85            row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
86        }
87        prev = row;
88    }
89    1.0 - prev[b.len()] as f64 / longest as f64
90}
91
92/// How closely `expected` appears somewhere in `heard`: the best
93/// [`similarity`] between `expected` and a run of consecutive words of
94/// `heard` about as long as it (within a tenth either way). A model turn
95/// that says the required text and something else, such as "Welcome to
96/// Bright Smile. This call may be recorded. How can I help?", scores as the
97/// text alone.
98pub fn contained_similarity(expected: &str, heard: &str) -> f64 {
99    let words = |s: &str| -> Vec<String> {
100        s.split_whitespace()
101            .map(|w| {
102                w.chars()
103                    .filter(|c| c.is_alphanumeric())
104                    .flat_map(char::to_lowercase)
105                    .collect::<String>()
106            })
107            .filter(|w| !w.is_empty())
108            .collect()
109    };
110    let (want, got) = (words(expected), words(heard));
111    let n = want.len();
112    if n == 0 || got.len() <= n {
113        return similarity(expected, heard);
114    }
115    let slack = (n / 10).max(1);
116    let want = want.join(" ");
117    let mut best = 0.0_f64;
118    for len in n.saturating_sub(slack).max(1)..=(n + slack).min(got.len()) {
119        for start in 0..=got.len() - len {
120            best = best.max(similarity(&want, &got[start..start + len].join(" ")));
121            if best >= 1.0 {
122                return best;
123            }
124        }
125    }
126    best
127}
128
129/// Check a finished model turn against the active verbatim requirement, if
130/// any, and record the verdict in state. `heard` is the turn's output
131/// transcript; an empty transcript checks nothing.
132pub fn check_turn(state: &State, heard: &str) -> Option<VerbatimVerdict> {
133    let requirement = state.get::<VerbatimRequirement>(VERBATIM_KEY)?;
134    if heard.trim().is_empty() {
135        return None;
136    }
137    let similarity = contained_similarity(&requirement.text, heard);
138    let passed = similarity >= VERBATIM_MIN_SIMILARITY;
139    // Once said verbatim, a later turn in the same stage cannot undo it.
140    let flag = verbatim_flag(&requirement.step);
141    if passed || state.get::<bool>(&flag) != Some(true) {
142        let _ = state.set(&flag, passed);
143    }
144    Some(VerbatimVerdict {
145        step: requirement.step,
146        similarity,
147        passed,
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    const TERMS: &str = "Calls may be recorded for quality and training purposes.";
156
157    #[test]
158    fn the_text_may_share_the_turn_with_other_words() {
159        let disclosure = "This call may be recorded.";
160        let turn = "Thanks for calling Trattoria Rustica. This call may be recorded. \
161                    How many guests, and for when?";
162        assert_eq!(contained_similarity(disclosure, turn), 1.0);
163        assert!(similarity(disclosure, turn) < VERBATIM_MIN_SIMILARITY);
164        // One recogniser slip in a long passage inside a longer turn passes.
165        let long = "You are speaking with an automated assistant and this call may be recorded for quality.";
166        let heard = "Hello! You are speaking with an automated assistant and this call may be recorded for quality. How can I help?";
167        assert!(contained_similarity(long, heard) >= VERBATIM_MIN_SIMILARITY);
168        let slipped = heard.replace("recorded", "recorder");
169        assert!(contained_similarity(long, &slipped) >= VERBATIM_MIN_SIMILARITY);
170        // A paraphrase still fails.
171        assert!(
172            contained_similarity(
173                disclosure,
174                "Hi! We might record this, just so you know. How can I help?"
175            ) < VERBATIM_MIN_SIMILARITY
176        );
177        // A turn shorter than the text compares as a whole.
178        assert_eq!(
179            contained_similarity(disclosure, "This call"),
180            similarity(disclosure, "This call")
181        );
182    }
183
184    #[test]
185    fn exact_and_near_exact_pass_a_paraphrase_does_not() {
186        assert_eq!(similarity(TERMS, TERMS), 1.0);
187        assert_eq!(
188            similarity(
189                TERMS,
190                "calls may be recorded, for quality and training purposes"
191            ),
192            1.0,
193            "case and punctuation do not count"
194        );
195        let slip = similarity(
196            TERMS,
197            "Calls may be recorded for quality and trading purposes.",
198        );
199        assert!((0.85..1.0).contains(&slip), "{slip}");
200        assert!(similarity(TERMS, "We might record this call.") < 0.5);
201    }
202
203    #[test]
204    fn a_turn_is_checked_against_the_published_requirement() {
205        let state = State::new();
206        assert_eq!(check_turn(&state, TERMS), None, "no verbatim stage active");
207
208        let _ = state.set(
209            VERBATIM_KEY,
210            VerbatimRequirement {
211                step: "terms".into(),
212                text: TERMS.into(),
213            },
214        );
215        let miss = check_turn(&state, "We might record this call.").unwrap();
216        assert!(!miss.passed);
217        assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(false));
218
219        let hit = check_turn(&state, TERMS).unwrap();
220        assert!(hit.passed);
221        assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(true));
222
223        // A later chatty turn does not undo a verbatim reading.
224        check_turn(&state, "Anything else?");
225        assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(true));
226    }
227}