gemini_adk_rs/flow/
verbatim.rs1use serde::{Deserialize, Serialize};
24
25use crate::state::State;
26
27pub const VERBATIM_KEY: &str = "session:verbatim";
29
30pub const VERBATIM_MIN_SIMILARITY: f64 = 0.9;
33
34pub fn verbatim_flag(step: &str) -> String {
36 format!("verbatim:{step}")
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct VerbatimRequirement {
42 pub step: String,
44 pub text: String,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct VerbatimVerdict {
51 pub step: String,
53 pub similarity: f64,
55 pub passed: bool,
57}
58
59pub 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 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
92pub 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
129pub 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 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 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 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 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 check_turn(&state, "Anything else?");
225 assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(true));
226 }
227}