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 compares the model's
11//!   output transcript with the text ([`similarity`]) and writes the verdict
12//!   to [`verbatim_flag`]`(step)`. It also emits `LiveEvent::VerbatimChecked`.
13//! - The stage completes only once the flag is true. A paraphrase keeps the
14//!   conversation in the stage, where the posture asks for the exact text
15//!   again.
16//!
17//! The check needs output transcription, since it reads what was actually
18//! said. Without a transcript nothing is verified, and the stage does not
19//! complete.
20
21use serde::{Deserialize, Serialize};
22
23use crate::state::State;
24
25/// The state key under which the active verbatim requirement is published.
26pub const VERBATIM_KEY: &str = "session:verbatim";
27
28/// How close (0–1, word level) the spoken text must be to the required text.
29/// Tolerates a transcription slip or two in a long passage, not a paraphrase.
30pub const VERBATIM_MIN_SIMILARITY: f64 = 0.9;
31
32/// The state key holding whether `step`'s text was said verbatim.
33pub fn verbatim_flag(step: &str) -> String {
34    format!("verbatim:{step}")
35}
36
37/// The published requirement of the active verbatim stage.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct VerbatimRequirement {
40    /// The stage that requires it.
41    pub step: String,
42    /// The text to be said.
43    pub text: String,
44}
45
46/// The outcome of one verbatim check.
47#[derive(Debug, Clone, PartialEq)]
48pub struct VerbatimVerdict {
49    /// The stage checked.
50    pub step: String,
51    /// Word-level similarity of what was said to what was required.
52    pub similarity: f64,
53    /// Whether it meets [`VERBATIM_MIN_SIMILARITY`].
54    pub passed: bool,
55}
56
57/// Word-level similarity of `heard` to `expected`, from 0 to 1: one minus
58/// the word edit distance over the length of the longer text, after
59/// lowercasing and dropping punctuation.
60pub fn similarity(expected: &str, heard: &str) -> f64 {
61    let words = |s: &str| -> Vec<String> {
62        s.split_whitespace()
63            .map(|w| {
64                w.chars()
65                    .filter(|c| c.is_alphanumeric())
66                    .flat_map(char::to_lowercase)
67                    .collect::<String>()
68            })
69            .filter(|w| !w.is_empty())
70            .collect()
71    };
72    let (a, b) = (words(expected), words(heard));
73    let longest = a.len().max(b.len());
74    if longest == 0 {
75        return 1.0;
76    }
77    // Levenshtein over words, one row at a time.
78    let mut prev: Vec<usize> = (0..=b.len()).collect();
79    for (i, wa) in a.iter().enumerate() {
80        let mut row = vec![i + 1; b.len() + 1];
81        for (j, wb) in b.iter().enumerate() {
82            let cost = usize::from(wa != wb);
83            row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
84        }
85        prev = row;
86    }
87    1.0 - prev[b.len()] as f64 / longest as f64
88}
89
90/// Check a finished model turn against the active verbatim requirement, if
91/// any, and record the verdict in state. `heard` is the turn's output
92/// transcript; an empty transcript checks nothing.
93pub fn check_turn(state: &State, heard: &str) -> Option<VerbatimVerdict> {
94    let requirement = state.get::<VerbatimRequirement>(VERBATIM_KEY)?;
95    if heard.trim().is_empty() {
96        return None;
97    }
98    let similarity = similarity(&requirement.text, heard);
99    let passed = similarity >= VERBATIM_MIN_SIMILARITY;
100    // Once said verbatim, a later turn in the same stage cannot undo it.
101    let flag = verbatim_flag(&requirement.step);
102    if passed || state.get::<bool>(&flag) != Some(true) {
103        let _ = state.set(&flag, passed);
104    }
105    Some(VerbatimVerdict {
106        step: requirement.step,
107        similarity,
108        passed,
109    })
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    const TERMS: &str = "Calls may be recorded for quality and training purposes.";
117
118    #[test]
119    fn exact_and_near_exact_pass_a_paraphrase_does_not() {
120        assert_eq!(similarity(TERMS, TERMS), 1.0);
121        assert_eq!(
122            similarity(
123                TERMS,
124                "calls may be recorded, for quality and training purposes"
125            ),
126            1.0,
127            "case and punctuation do not count"
128        );
129        let slip = similarity(
130            TERMS,
131            "Calls may be recorded for quality and trading purposes.",
132        );
133        assert!((0.85..1.0).contains(&slip), "{slip}");
134        assert!(similarity(TERMS, "We might record this call.") < 0.5);
135    }
136
137    #[test]
138    fn a_turn_is_checked_against_the_published_requirement() {
139        let state = State::new();
140        assert_eq!(check_turn(&state, TERMS), None, "no verbatim stage active");
141
142        let _ = state.set(
143            VERBATIM_KEY,
144            VerbatimRequirement {
145                step: "terms".into(),
146                text: TERMS.into(),
147            },
148        );
149        let miss = check_turn(&state, "We might record this call.").unwrap();
150        assert!(!miss.passed);
151        assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(false));
152
153        let hit = check_turn(&state, TERMS).unwrap();
154        assert!(hit.passed);
155        assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(true));
156
157        // A later chatty turn does not undo a verbatim reading.
158        check_turn(&state, "Anything else?");
159        assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(true));
160    }
161}