Skip to main content

exception_collector/
llm.rs

1//! LLM-based semantic deduplication and issue content generation.
2//!
3//! The [`LlmClassifier`] sends batches of exceptions to an LLM through
4//! a configurable channel for semantic deduplication.
5//!
6//! # Fallback strategy
7//!
8//! | Scenario | Handling |
9//! |----------|----------|
10//! | LLM timeout (>30s) | Fall back to local signature dedup |
11//! | LLM returns non-JSON | Retry once, then fall back |
12//! | All channels unavailable | Keep for next report cycle |
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18
19use crate::{CollectorError, CollectorResult, ExceptionRecord};
20
21// ── Channel Abstraction ─────────────────────────────────────────────────────
22
23/// Abstract LLM channel for classification requests.
24///
25/// Implementations wire into the existing agent-proxy-rust Channel system,
26/// or a mock for testing.
27#[async_trait]
28pub trait LlmChannel: Send + Sync {
29    /// Send a prompt to the LLM and return the raw response text.
30    ///
31    /// # Errors
32    ///
33    /// Returns `CollectorError::Reporter` on channel failure or timeout.
34    async fn send(&self, system_prompt: &str, user_prompt: &str) -> CollectorResult<String>;
35}
36
37// ── Classification Types ────────────────────────────────────────────────────
38
39/// The LLM's decision for a single exception record.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ClassificationResult {
42    /// The dedup signature of the exception.
43    pub signature: String,
44    /// The action to take.
45    pub action: ClassificationAction,
46}
47
48/// Action determined by LLM classification.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(untagged)]
51pub enum ClassificationAction {
52    /// This exception matches an existing open issue.
53    Duplicate {
54        /// The GitHub issue number to comment on.
55        issue_number: u32,
56    },
57    /// This is a new issue that should be created.
58    NewIssue {
59        /// LLM-generated issue title.
60        title: String,
61        /// LLM-generated issue body.
62        body: String,
63    },
64}
65
66// ── LlmClassifier ───────────────────────────────────────────────────────────
67
68/// Classifies exception batches using an LLM channel.
69///
70/// Sends a batch of exceptions + existing open issues to the LLM
71/// for semantic deduplication and issue content generation.
72pub struct LlmClassifier {
73    /// The LLM channel for sending prompts.
74    channel: Arc<dyn LlmChannel>,
75    /// Timeout for each LLM request in seconds.
76    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/// JSON input format for the LLM: a list of exception records.
88#[derive(Debug, Serialize)]
89struct ExceptionInput {
90    signature: String,
91    component: String,
92    message: String,
93    stacktrace: String,
94}
95
96/// JSON input for an existing GitHub issue.
97#[derive(Debug, Serialize)]
98struct ExistingIssue {
99    number: u32,
100    title: String,
101    body: String,
102}
103
104impl LlmClassifier {
105    /// Create a new `LlmClassifier` with the given channel.
106    #[must_use]
107    pub fn new(channel: Arc<dyn LlmChannel>) -> Self {
108        Self {
109            channel,
110            timeout_secs: 30,
111        }
112    }
113
114    /// Classify a batch of exceptions against existing open issues.
115    ///
116    /// Sends one LLM request for the entire batch (not per-record).
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the LLM channel fails and fallback is not possible.
121    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        // Try LLM request
130        let response = self.channel.send(&system_prompt, &user_prompt).await;
131
132        let text = match response {
133            Ok(text) => text,
134            Err(e) => {
135                // Fallback: treat all as new issues with auto-generated content
136                let results = fallback_classify(records);
137                return if results.is_empty() {
138                    Err(e)
139                } else {
140                    Ok(results)
141                };
142            }
143        };
144
145        // Try parsing, retry once on failure
146        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/// Create an `LlmClassifier` using the default HTTP channel (from env vars).
157///
158/// Requires the `http-llm` feature.
159#[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/// Data about an existing open GitHub issue (for dedup comparison).
167#[derive(Debug, Clone)]
168pub struct ExistingIssueData {
169    /// GitHub issue number.
170    pub number: u32,
171    /// Issue title.
172    pub title: String,
173    /// Issue body.
174    pub body: String,
175}
176
177// ── Prompt Building ─────────────────────────────────────────────────────────
178
179const 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
222// ── Response Parsing ────────────────────────────────────────────────────────
223
224/// Parse the LLM's JSON response into classification results.
225fn parse_llm_response(text: &str) -> CollectorResult<Vec<ClassificationResult>> {
226    // Strip markdown code fences if present
227    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
238// ── Fallback ────────────────────────────────────────────────────────────────
239
240/// Generate classification results without LLM (local signature-based).
241///
242/// Each record is treated as a new issue with auto-generated content.
243fn 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
261/// Build an issue body for fallback (no LLM).
262fn 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// ── Tests ───────────────────────────────────────────────────────────────────
277
278#[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    /// Mock LLM channel that returns a predefined response.
293    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        // Fallback should treat all as new issues
397        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        // Should fail after retry — non-JSON both times
417        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}