diff --git a/backend/src/services/llm/mock.rs b/backend/src/services/llm/mock.rs new file mode 100644 index 0000000..e02604c --- /dev/null +++ b/backend/src/services/llm/mock.rs @@ -0,0 +1,134 @@ +//! Mock LLM provider for integration testing. + +use std::sync::Arc; +use async_trait::async_trait; +use serde_json::{json, Value}; +use crate::errors::AppError; +use super::LlmProvider; + +/// A mock LLM provider that returns deterministic responses. +pub struct MockLlmProvider { + default_category: String, + search_urls: Vec, + link_urls: Vec, +} + +impl MockLlmProvider { + pub fn new() -> Self { + Self { + default_category: "Autre".to_string(), + search_urls: Vec::new(), + link_urls: Vec::new(), + } + } + + pub fn with_default_category(mut self, category: &str) -> Self { + self.default_category = category.to_string(); + self + } + + pub fn with_search_urls(mut self, urls: Vec) -> Self { + self.search_urls = urls; + self + } + + pub fn with_link_urls(mut self, urls: Vec) -> Self { + self.link_urls = urls; + self + } + + pub fn into_arc(self) -> Arc { + Arc::new(self) + } +} + +#[async_trait] +impl LlmProvider for MockLlmProvider { + fn provider_id(&self) -> &str { + "mock" + } + + async fn call_llm( + &self, + _model: &str, + system_prompt: &str, + user_prompt: &str, + _response_schema: &Value, + ) -> Result { + let sys_lower = system_prompt.to_lowercase(); + + // Classify/summarize call + if sys_lower.contains("classer") { + let title = user_prompt + .lines() + .find(|l| l.starts_with("Titre : ")) + .map(|l| l.trim_start_matches("Titre : ").to_string()) + .unwrap_or_else(|| "Mock Article".to_string()); + + return Ok(json!({ + "title": title, + "summary": format!("Mock summary for: {}", title), + "category": self.default_category, + })); + } + + // Link extraction call + if sys_lower.contains("liens") { + return Ok(json!({ "urls": self.link_urls })); + } + + // Search call + if sys_lower.contains("precis") { + let items: Vec = self.search_urls.iter().map(|url| { + json!({ + "title": format!("Search result: {}", url), + "url": url, + "summary": format!("Mock search summary for {}", url), + }) + }).collect(); + return Ok(json!({ "category_0": items })); + } + + Ok(json!({"result": "mock response"})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mock_provider_returns_classify_response() { + let provider = MockLlmProvider::new().with_default_category("AI News"); + let result = provider + .call_llm("model", "Tu dois classer l'article", "Titre : GPT-7\n\nContenu...", &json!({})) + .await + .unwrap(); + assert_eq!(result["title"], "GPT-7"); + assert_eq!(result["category"], "AI News"); + } + + #[tokio::test] + async fn mock_provider_returns_search_response() { + let provider = MockLlmProvider::new() + .with_search_urls(vec!["http://example.com/a".into()]); + let result = provider + .call_llm("model", "Tu es un assistant IA precis", "Recherche...", &json!({})) + .await + .unwrap(); + let items = result["category_0"].as_array().unwrap(); + assert_eq!(items.len(), 1); + } + + #[tokio::test] + async fn mock_provider_returns_link_extraction() { + let provider = MockLlmProvider::new() + .with_link_urls(vec!["http://example.com/post-1".into()]); + let result = provider + .call_llm("model", "Tu dois identifier les liens", "Links...", &json!({})) + .await + .unwrap(); + let urls = result["urls"].as_array().unwrap(); + assert_eq!(urls.len(), 1); + } +} diff --git a/backend/src/services/llm/mod.rs b/backend/src/services/llm/mod.rs index 70a5b3e..d8132d3 100644 --- a/backend/src/services/llm/mod.rs +++ b/backend/src/services/llm/mod.rs @@ -6,6 +6,7 @@ pub mod anthropic; pub mod factory; pub mod gemini; +pub mod mock; pub mod openai; pub mod schema;