1use std::sync::Arc;
2use std::time::Duration;
3
4use regex::Regex;
5
6use crate::injection::InjectionLevel;
7use crate::tool::BoxFut;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ClassifierSource {
11 Prefix,
12 Rule,
13 Llm,
14 Default,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Classification {
19 pub level: InjectionLevel,
20 pub redirect_target: Option<String>,
21 pub source: ClassifierSource,
22}
23
24pub trait InjectionClassifier: Send + Sync {
25 fn classify<'a>(&'a self, text: &'a str) -> BoxFut<'a, Classification>;
26 fn kind(&self) -> &'static str;
27}
28
29pub struct RuleClassifier {
30 l4: Regex,
31 l3: Regex,
32 l2: Regex,
33}
34
35impl Default for RuleClassifier {
36 fn default() -> Self {
37 Self {
38 l4: Regex::new(r"^(?i)\s*(停|停下|停止|stop|abort|halt|kill|终止)[\s!.。!]*$")
39 .unwrap(),
40 l3: Regex::new(r"(?i)^\s*(换成|切换到|redirect to|switch to)\s+(\S+)").unwrap(),
41 l2: Regex::new(r"(别用|不要|不能|改成|错了|应该|请用|do not|don't use|stop using)")
42 .unwrap(),
43 }
44 }
45}
46
47impl RuleClassifier {
48 fn classify_sync(&self, text: &str) -> Classification {
49 let trimmed = text.trim();
50 if self.l4.is_match(trimmed) {
51 return Classification {
52 level: InjectionLevel::L4HardStop,
53 redirect_target: None,
54 source: ClassifierSource::Rule,
55 };
56 }
57 if let Some(caps) = self.l3.captures(trimmed) {
58 let target = caps.get(2).map(|m| m.as_str().to_string());
59 return Classification {
60 level: InjectionLevel::L3Redirect,
61 redirect_target: target,
62 source: ClassifierSource::Rule,
63 };
64 }
65 if self.l2.is_match(trimmed) {
66 return Classification {
67 level: InjectionLevel::L2CourseCorrect,
68 redirect_target: None,
69 source: ClassifierSource::Rule,
70 };
71 }
72 Classification {
73 level: InjectionLevel::L1Nudge,
74 redirect_target: None,
75 source: ClassifierSource::Default,
76 }
77 }
78}
79
80impl InjectionClassifier for RuleClassifier {
81 fn classify<'a>(&'a self, text: &'a str) -> BoxFut<'a, Classification> {
82 let out = self.classify_sync(text);
83 Box::pin(async move { out })
84 }
85 fn kind(&self) -> &'static str {
86 "rule"
87 }
88}
89
90pub struct LlmClassifier {
91 provider: Arc<dyn crate::provider::Provider>,
92 model: String,
93}
94
95impl LlmClassifier {
96 pub fn new(provider: Arc<dyn crate::provider::Provider>, model: impl Into<String>) -> Self {
97 Self {
98 provider,
99 model: model.into(),
100 }
101 }
102}
103
104impl InjectionClassifier for LlmClassifier {
105 fn classify<'a>(&'a self, text: &'a str) -> BoxFut<'a, Classification> {
106 Box::pin(async move {
107 let prompt = format!(
108 "You are classifying a user interruption sent to a running AI agent. Reply STRICTLY as one line of JSON, no prose.\n\n\
109 Levels:\n\
110 - L1: minor nudge (add info, remind, small hint)\n\
111 - L2: course-correct (wrong approach mid-stream, forbidden pattern)\n\
112 - L3: redirect (switch flows entirely, name a target if user gave one)\n\
113 - L4: hard stop (kill flow immediately)\n\n\
114 Reply shape: {{\"level\": \"L1\"|\"L2\"|\"L3\"|\"L4\", \"redirect_target\": null | \"<flow_name>\"}}\n\n\
115 User interruption: {text}"
116 );
117 let req = crate::provider::LlmRequest {
118 model: self.model.clone(),
119 messages: vec![crate::provider::user_text_message(prompt)],
120 system: None,
121 input: crate::value::Value::Unit,
122 schema: None,
123 cache_prompt: false,
124 tools: Vec::new(),
125 thinking_enabled: false,
126 stall_timeout_secs: 0,
127 };
128 let am = match self.provider.call(req).await {
129 Ok(am) => am,
130 Err(_) => return default_l1(ClassifierSource::Default),
131 };
132 let body = am.message.text_concat();
133 let Some(parsed) =
134 extract_json(&body).and_then(|s| serde_json::from_str::<ClassifyJson>(&s).ok())
135 else {
136 return default_l1(ClassifierSource::Default);
137 };
138 Classification {
139 level: parse_level(&parsed.level),
140 redirect_target: parsed.redirect_target,
141 source: ClassifierSource::Llm,
142 }
143 })
144 }
145 fn kind(&self) -> &'static str {
146 "llm"
147 }
148}
149
150#[derive(serde::Deserialize)]
151struct ClassifyJson {
152 level: String,
153 #[serde(default)]
154 redirect_target: Option<String>,
155}
156
157fn parse_level(s: &str) -> InjectionLevel {
158 match s.trim().to_ascii_lowercase().as_str() {
159 "l4" => InjectionLevel::L4HardStop,
160 "l3" => InjectionLevel::L3Redirect,
161 "l2" => InjectionLevel::L2CourseCorrect,
162 _ => InjectionLevel::L1Nudge,
163 }
164}
165
166fn extract_json(body: &str) -> Option<String> {
167 let start = body.find('{')?;
168 let end = body.rfind('}')?;
169 if end < start {
170 return None;
171 }
172 Some(body[start..=end].to_string())
173}
174
175fn default_l1(source: ClassifierSource) -> Classification {
176 Classification {
177 level: InjectionLevel::L1Nudge,
178 redirect_target: None,
179 source,
180 }
181}
182
183pub struct ComposedClassifier {
184 rule: RuleClassifier,
185 llm: Option<Arc<dyn InjectionClassifier>>,
186 llm_timeout: Duration,
187}
188
189impl ComposedClassifier {
190 pub fn new(rule: RuleClassifier) -> Self {
191 Self {
192 rule,
193 llm: None,
194 llm_timeout: Duration::from_secs(3),
195 }
196 }
197
198 pub fn with_llm(mut self, llm: Arc<dyn InjectionClassifier>, timeout: Duration) -> Self {
199 self.llm = Some(llm);
200 self.llm_timeout = timeout;
201 self
202 }
203}
204
205impl InjectionClassifier for ComposedClassifier {
206 fn classify<'a>(&'a self, text: &'a str) -> BoxFut<'a, Classification> {
207 Box::pin(async move {
208 let rule_hit = self.rule.classify_sync(text);
209 if rule_hit.level != InjectionLevel::L1Nudge {
210 return rule_hit;
211 }
212 let Some(llm) = self.llm.as_ref() else {
213 return rule_hit;
214 };
215 match tokio::time::timeout(self.llm_timeout, llm.classify(text)).await {
216 Ok(cls) => cls,
217 Err(_) => rule_hit,
218 }
219 })
220 }
221 fn kind(&self) -> &'static str {
222 "composed"
223 }
224}
225
226pub fn source_tag(source: ClassifierSource) -> &'static str {
227 match source {
228 ClassifierSource::Prefix => "prefix",
229 ClassifierSource::Rule => "rule",
230 ClassifierSource::Llm => "llm",
231 ClassifierSource::Default => "default",
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[tokio::test]
240 async fn rule_matches_l4_stop_variants() {
241 let c = RuleClassifier::default();
242 for text in ["停", "停下!", "stop", "STOP", "abort.", "kill"] {
243 let out = c.classify(text).await;
244 assert_eq!(out.level, InjectionLevel::L4HardStop, "text: {text}");
245 assert_eq!(out.source, ClassifierSource::Rule);
246 }
247 }
248
249 #[tokio::test]
250 async fn rule_matches_l3_redirect_and_extracts_target() {
251 let c = RuleClassifier::default();
252 let out = c.classify("换成 review_code").await;
253 assert_eq!(out.level, InjectionLevel::L3Redirect);
254 assert_eq!(out.redirect_target.as_deref(), Some("review_code"));
255 let out = c.classify("switch to hello").await;
256 assert_eq!(out.level, InjectionLevel::L3Redirect);
257 assert_eq!(out.redirect_target.as_deref(), Some("hello"));
258 }
259
260 #[tokio::test]
261 async fn rule_matches_l2_course_correct_hints() {
262 let c = RuleClassifier::default();
263 for text in [
264 "别用 as any",
265 "不要写 unsafe",
266 "改成 async",
267 "错了 应该用 tokio",
268 "do not use blocking",
269 ] {
270 let out = c.classify(text).await;
271 assert_eq!(out.level, InjectionLevel::L2CourseCorrect, "text: {text}");
272 }
273 }
274
275 #[tokio::test]
276 async fn rule_falls_back_to_l1_nudge_default() {
277 let c = RuleClassifier::default();
278 let out = c.classify("记得加一句测试").await;
279 assert_eq!(out.level, InjectionLevel::L1Nudge);
280 assert_eq!(out.source, ClassifierSource::Default);
281 }
282
283 #[tokio::test]
284 async fn composed_returns_rule_hit_when_matched() {
285 let c = ComposedClassifier::new(RuleClassifier::default());
286 let out = c.classify("停").await;
287 assert_eq!(out.level, InjectionLevel::L4HardStop);
288 }
289}