gemini_adk_rs/flow/
verbatim.rs1use serde::{Deserialize, Serialize};
22
23use crate::state::State;
24
25pub const VERBATIM_KEY: &str = "session:verbatim";
27
28pub const VERBATIM_MIN_SIMILARITY: f64 = 0.9;
31
32pub fn verbatim_flag(step: &str) -> String {
34 format!("verbatim:{step}")
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct VerbatimRequirement {
40 pub step: String,
42 pub text: String,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct VerbatimVerdict {
49 pub step: String,
51 pub similarity: f64,
53 pub passed: bool,
55}
56
57pub 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 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
90pub 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 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 check_turn(&state, "Anything else?");
159 assert_eq!(state.get::<bool>(&verbatim_flag("terms")), Some(true));
160 }
161}