1use std::sync::Arc;
15
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18
19use crate::{CollectorError, CollectorResult, ExceptionRecord};
20
21#[async_trait]
28pub trait LlmChannel: Send + Sync {
29 async fn send(&self, system_prompt: &str, user_prompt: &str) -> CollectorResult<String>;
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ClassificationResult {
42 pub signature: String,
44 pub action: ClassificationAction,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(untagged)]
51pub enum ClassificationAction {
52 Duplicate {
54 issue_number: u32,
56 },
57 NewIssue {
59 title: String,
61 body: String,
63 },
64}
65
66pub struct LlmClassifier {
73 channel: Arc<dyn LlmChannel>,
75 timeout_secs: u64,
77}
78
79impl std::fmt::Debug for LlmClassifier {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("LlmClassifier")
82 .field("timeout_secs", &self.timeout_secs)
83 .finish_non_exhaustive()
84 }
85}
86
87#[derive(Debug, Serialize)]
89struct ExceptionInput {
90 signature: String,
91 component: String,
92 message: String,
93 stacktrace: String,
94}
95
96#[derive(Debug, Serialize)]
98struct ExistingIssue {
99 number: u32,
100 title: String,
101 body: String,
102}
103
104impl LlmClassifier {
105 #[must_use]
107 pub fn new(channel: Arc<dyn LlmChannel>) -> Self {
108 Self {
109 channel,
110 timeout_secs: 30,
111 }
112 }
113
114 pub async fn classify_batch(
122 &self,
123 records: &[ExceptionRecord],
124 existing_issues: &[ExistingIssueData],
125 ) -> CollectorResult<Vec<ClassificationResult>> {
126 let system_prompt = build_system_prompt();
127 let user_prompt = build_user_prompt(records, existing_issues);
128
129 let response = self.channel.send(&system_prompt, &user_prompt).await;
131
132 let text = match response {
133 Ok(text) => text,
134 Err(e) => {
135 let results = fallback_classify(records);
137 return if results.is_empty() {
138 Err(e)
139 } else {
140 Ok(results)
141 };
142 }
143 };
144
145 let Ok(results) = parse_llm_response(&text) else {
147 let retry = self.channel.send(&system_prompt, &user_prompt).await?;
148 return parse_llm_response(&retry).map_err(|_| CollectorError::Reporter {
149 reason: "LLM response not valid JSON after retry".to_string(),
150 });
151 };
152 Ok(results)
153 }
154}
155
156#[cfg(feature = "http-llm")]
160#[must_use]
161pub fn default_classifier() -> LlmClassifier {
162 use crate::channel::HttpLlmChannel;
163 LlmClassifier::new(Arc::new(HttpLlmChannel::from_env()))
164}
165
166#[derive(Debug, Clone)]
168pub struct ExistingIssueData {
169 pub number: u32,
171 pub title: String,
173 pub body: String,
175}
176
177const SYSTEM_PROMPT: &str =
180 "\
181You are a TokenFleet exception classification assistant. Your job is to determine whether each new \
182 exception matches an existing open issue (semantic duplicate) or is truly new. For \
183 duplicates: reference the existing issue number. For new issues: generate a concise title \
184 and a structured body with: exception summary, component, module path, location, and stack \
185 trace. Respond ONLY with a JSON array of objects, each with keys: \"signature\", \"action\" \
186 (\"duplicate\" or \"new_issue\"), \"issue_number\" (for duplicates), \"title\" and \"body\" \
187 (for new issues).";
188
189fn build_system_prompt() -> String {
190 SYSTEM_PROMPT.to_string()
191}
192
193fn build_user_prompt(records: &[ExceptionRecord], existing_issues: &[ExistingIssueData]) -> String {
194 let exceptions: Vec<ExceptionInput> = records
195 .iter()
196 .map(|r| ExceptionInput {
197 signature: r.dedup_signature.clone(),
198 component: r.component.clone(),
199 message: r.message.clone(),
200 stacktrace: r.stacktrace.clone(),
201 })
202 .collect();
203
204 let issues: Vec<ExistingIssue> = existing_issues
205 .iter()
206 .map(|i| ExistingIssue {
207 number: i.number,
208 title: i.title.clone(),
209 body: i.body.clone(),
210 })
211 .collect();
212
213 let exceptions_json = serde_json::to_string(&exceptions).unwrap_or_default();
214 let issues_json = serde_json::to_string(&issues).unwrap_or_default();
215
216 format!(
217 "## Existing Open Issues (JSON)\n{issues_json}\n\n## New Exceptions \
218 (JSON)\n{exceptions_json}"
219 )
220}
221
222fn parse_llm_response(text: &str) -> CollectorResult<Vec<ClassificationResult>> {
226 let json_text = text
228 .trim()
229 .strip_prefix("```json")
230 .and_then(|s| s.strip_suffix("```"))
231 .map_or(text.trim(), str::trim);
232
233 let results: Vec<ClassificationResult> =
234 serde_json::from_str(json_text).map_err(CollectorError::SerdeJson)?;
235 Ok(results)
236}
237
238fn fallback_classify(records: &[ExceptionRecord]) -> Vec<ClassificationResult> {
244 records
245 .iter()
246 .map(|r| ClassificationResult {
247 signature: r.dedup_signature.clone(),
248 action: ClassificationAction::NewIssue {
249 title: format!(
250 "[{}] {}: {}",
251 r.component,
252 r.kind,
253 &r.message[..r.message.len().min(80)]
254 ),
255 body: build_fallback_body(r),
256 },
257 })
258 .collect()
259}
260
261fn build_fallback_body(record: &ExceptionRecord) -> String {
263 format!(
264 "## 异常摘要\n{message}\n\n## 堆栈\n```\n{stacktrace}\n```\n\n## 影响范围\n- 组件: \
265 {component}\n- 模块: {module}\n- 位置: {location}\n- 时间: {timestamp}\n\n---\n> 由 \
266 TokenFleet Exception Collector 自动生成(降级模式)",
267 message = record.message,
268 stacktrace = record.stacktrace,
269 component = record.component,
270 module = record.module_path.as_deref().unwrap_or("unknown"),
271 location = record.location.as_deref().unwrap_or("unknown"),
272 timestamp = record.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
273 )
274}
275
276#[cfg(test)]
279#[allow(
280 clippy::unwrap_used,
281 clippy::unwrap_in_result,
282 clippy::expect_used,
283 clippy::panic,
284 clippy::pedantic,
285 clippy::indexing_slicing,
286 reason = "test module relaxes production lint strictness"
287)]
288mod tests {
289 use super::*;
290 use crate::{ExceptionKind, ExceptionRecord};
291
292 struct MockChannel {
294 response: String,
295 should_fail: bool,
296 }
297
298 #[async_trait]
299 impl LlmChannel for MockChannel {
300 async fn send(&self, _system: &str, _user: &str) -> CollectorResult<String> {
301 if self.should_fail {
302 Err(CollectorError::Reporter {
303 reason: "mock channel failure".to_string(),
304 })
305 } else {
306 Ok(self.response.clone())
307 }
308 }
309 }
310
311 fn make_record(signature: &str, message: &str) -> ExceptionRecord {
312 let mut record =
313 ExceptionRecord::new("test-comp", ExceptionKind::ErrorLog, message, "stack trace");
314 record.dedup_signature = signature.to_string();
315 record
316 }
317
318 fn duplicate_response(sig: &str, issue: u32) -> String {
319 format!(r#"[{{"signature":"{sig}","action":{{"issue_number":{issue}}}}}]"#)
320 }
321
322 fn new_issue_response(sig: &str) -> String {
323 format!(r#"[{{"signature":"{sig}","action":{{"title":"Test Issue","body":"Test body"}}}}]"#)
324 }
325
326 #[test]
327 fn test_should_detect_duplicate_via_llm() {
328 let response = duplicate_response("abc123", 42);
329 let channel = Arc::new(MockChannel {
330 response,
331 should_fail: false,
332 });
333 let classifier = LlmClassifier::new(channel);
334
335 let records = vec![make_record("abc123", "test error")];
336 let existing = vec![ExistingIssueData {
337 number: 42,
338 title: "Existing issue".to_string(),
339 body: "Old body".to_string(),
340 }];
341
342 let rt = tokio::runtime::Runtime::new().unwrap();
343 let results = rt
344 .block_on(classifier.classify_batch(&records, &existing))
345 .unwrap();
346
347 assert_eq!(results.len(), 1);
348 assert_eq!(results[0].signature, "abc123");
349 match &results[0].action {
350 ClassificationAction::Duplicate { issue_number } => {
351 assert_eq!(*issue_number, 42);
352 }
353 _ => panic!("expected Duplicate"),
354 }
355 }
356
357 #[test]
358 fn test_should_generate_new_issue_via_llm() {
359 let response = new_issue_response("xyz789");
360 let channel = Arc::new(MockChannel {
361 response,
362 should_fail: false,
363 });
364 let classifier = LlmClassifier::new(channel);
365
366 let records = vec![make_record("xyz789", "new error")];
367 let rt = tokio::runtime::Runtime::new().unwrap();
368 let results = rt
369 .block_on(classifier.classify_batch(&records, &[]))
370 .unwrap();
371
372 assert_eq!(results.len(), 1);
373 match &results[0].action {
374 ClassificationAction::NewIssue { title, body } => {
375 assert_eq!(title, "Test Issue");
376 assert_eq!(body, "Test body");
377 }
378 _ => panic!("expected NewIssue"),
379 }
380 }
381
382 #[test]
383 fn test_should_fallback_on_llm_failure() {
384 let channel = Arc::new(MockChannel {
385 response: String::new(),
386 should_fail: true,
387 });
388 let classifier = LlmClassifier::new(channel);
389
390 let records = vec![make_record("sig1", "test error")];
391 let rt = tokio::runtime::Runtime::new().unwrap();
392 let results = rt
393 .block_on(classifier.classify_batch(&records, &[]))
394 .unwrap();
395
396 assert_eq!(results.len(), 1);
398 match &results[0].action {
399 ClassificationAction::NewIssue { title, .. } => {
400 assert!(title.contains("test error"));
401 }
402 _ => panic!("expected fallback NewIssue"),
403 }
404 }
405
406 #[test]
407 fn test_should_fallback_on_non_json_response() {
408 let channel = Arc::new(MockChannel {
409 response: "not valid json at all".to_string(),
410 should_fail: false,
411 });
412 let classifier = LlmClassifier::new(channel);
413
414 let records = vec![make_record("sig1", "test")];
415 let rt = tokio::runtime::Runtime::new().unwrap();
416 let result = rt.block_on(classifier.classify_batch(&records, &[]));
418
419 assert!(result.is_err());
420 }
421
422 #[test]
423 fn test_parse_llm_response_should_handle_json_with_code_fence() {
424 let text = r###"```json
425[{"signature":"abc","action":{"title":"T","body":"B"}}]
426```"###;
427 let results = parse_llm_response(text).unwrap();
428 assert_eq!(results.len(), 1);
429 }
430
431 #[test]
432 fn test_parse_llm_response_should_handle_plain_json() {
433 let text = r#"[{"signature":"abc","action":{"title":"T","body":"B"}}]"#;
434 let results = parse_llm_response(text).unwrap();
435 assert_eq!(results.len(), 1);
436 }
437
438 #[test]
439 fn test_parse_llm_response_should_error_on_invalid() {
440 let text = "not json";
441 assert!(parse_llm_response(text).is_err());
442 }
443
444 #[test]
445 fn test_build_fallback_body_should_include_component() {
446 let mut record = make_record("sig", "test message");
447 record.module_path = Some("mod".to_string());
448 record.location = Some("file.rs:1".to_string());
449
450 let body = build_fallback_body(&record);
451 assert!(body.contains("test message"));
452 assert!(body.contains("test-comp"));
453 assert!(body.contains("降级模式"));
454 }
455
456 #[test]
457 fn test_system_prompt_should_be_non_empty() {
458 let prompt = build_system_prompt();
459 assert!(!prompt.is_empty());
460 assert!(prompt.contains("TokenFleet"));
461 }
462
463 #[test]
464 fn test_user_prompt_should_include_records() {
465 let records = vec![make_record("sig-a", "error one")];
466 let issues = vec![ExistingIssueData {
467 number: 1,
468 title: "Old".to_string(),
469 body: "Old body".to_string(),
470 }];
471
472 let prompt = build_user_prompt(&records, &issues);
473 assert!(prompt.contains("sig-a"));
474 assert!(prompt.contains("error one"));
475 assert!(prompt.contains("Old"));
476 assert!(prompt.contains("Existing Open Issues"));
477 }
478
479 #[test]
480 fn test_classification_action_serde_duplicate() {
481 let action = ClassificationAction::Duplicate { issue_number: 5 };
482 let json = serde_json::to_string(&action).unwrap();
483 assert!(json.contains("issue_number"));
484 assert!(json.contains("5"));
485 }
486
487 #[test]
488 fn test_classification_action_serde_new_issue() {
489 let action = ClassificationAction::NewIssue {
490 title: "T".to_string(),
491 body: "B".to_string(),
492 };
493 let json = serde_json::to_string(&action).unwrap();
494 assert!(json.contains("title"));
495 assert!(json.contains("B"));
496 }
497}