//! LLM (Large Language Model) integration module //! Provides Claude API integration for RAG-based answer generation use serde::{Deserialize, Serialize}; use tracing::info; use crate::error::Result; /// Claude API request message #[derive(Debug, Clone, Serialize)] pub struct ClaudeMessage { pub role: String, pub content: String, } /// Claude API response #[derive(Debug, Clone, Deserialize)] pub struct ClaudeResponse { pub content: Vec, pub stop_reason: String, } /// Claude response content #[derive(Debug, Clone, Deserialize)] pub struct ClaudeContent { #[serde(rename = "type")] pub content_type: String, pub text: Option, } /// LLM Client for Claude API pub struct LlmClient { api_key: String, pub model: String, base_url: String, } impl LlmClient { /// Create a new Claude LLM client pub fn new(model: String) -> Result { // Get API key from environment let api_key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_else(|_| { tracing::warn!("ANTHROPIC_API_KEY not set - Claude API calls will fail"); String::new() }); Ok(Self { api_key, model, base_url: "https://api.anthropic.com/v1".to_string(), }) } /// Generate an answer using Claude pub async fn generate_answer(&self, query: &str, context: &str) -> Result { // If no API key, return placeholder if self.api_key.is_empty() { return Ok(self.generate_placeholder(query, context)); } // Build the system prompt let system_prompt = format!( r#"You are a helpful assistant answering questions about a provisioning platform. You have been provided with relevant documentation context below. Answer the user's question based on this context. Be concise and accurate. # Retrieved Context {} "#, context ); // Build the user message let user_message = query.to_string(); // Call Claude API self.call_claude_api(&system_prompt, &user_message).await } /// Call Claude API with messages async fn call_claude_api(&self, system: &str, user_message: &str) -> Result { let client = reqwest::Client::new(); // Build request payload let payload = serde_json::json!({ "model": self.model, "max_tokens": 1024, "system": system, "messages": [ { "role": "user", "content": user_message } ] }); // Make the API request let response = client .post(format!("{}/messages", self.base_url)) .header("anthropic-version", "2023-06-01") .header("x-api-key", &self.api_key) .json(&payload) .send() .await .map_err(|e| { crate::error::RagError::LlmError(format!("Claude API request failed: {}", e)) })?; // Check status if !response.status().is_success() { let status = response.status(); let error_text = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(crate::error::RagError::LlmError(format!( "Claude API error {}: {}", status, error_text ))); } // Parse response let claude_response: ClaudeResponse = response.json().await.map_err(|e| { crate::error::RagError::LlmError(format!("Failed to parse Claude response: {}", e)) })?; // Extract text from response let answer = claude_response .content .first() .and_then(|c| c.text.clone()) .ok_or_else(|| { crate::error::RagError::LlmError("No text in Claude response".to_string()) })?; info!( "Claude API call successful, generated {} characters", answer.len() ); Ok(answer) } /// Generate placeholder answer when API key is missing fn generate_placeholder(&self, query: &str, context: &str) -> String { format!( "Based on the provided context about the provisioning platform:\n\n{}\n\n(Note: This \ is a placeholder response. Set ANTHROPIC_API_KEY environment variable for full \ Claude integration.)", self.format_context_summary(query, context) ) } /// Format a summary of the context fn format_context_summary(&self, query: &str, context: &str) -> String { let context_lines = context.lines().count(); let query_lower = query.to_lowercase(); if query_lower.contains("deploy") || query_lower.contains("create") { format!( "Your question about deployment is addressed in {} lines of documentation. The \ system supports multi-cloud deployment across AWS, UpCloud, and local \ environments.", context_lines ) } else if query_lower.contains("architecture") || query_lower.contains("design") { format!( "The provisioning platform uses a modular architecture as described in {} lines \ of documentation. Core components include Orchestrator, Control Center, and MCP \ Server integration.", context_lines ) } else if query_lower.contains("security") || query_lower.contains("auth") { format!( "Security features are documented in {} lines. The system implements JWT-based \ authentication, Cedar-based authorization, and dynamic secrets management.", context_lines ) } else { format!( "Your question is addressed in the provided {} lines of documentation. Please \ review the context above for details.", context_lines ) } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_llm_client_creation() { let client = LlmClient::new("claude-opus-4-1".to_string()); assert!(client.is_ok()); } #[test] fn test_placeholder_generation() { let client = LlmClient { api_key: String::new(), model: "claude-opus-4-1".to_string(), base_url: "https://api.anthropic.com/v1".to_string(), }; let query = "How do I deploy the platform?"; let context = "Deployment is done using provisioning commands"; let answer = client.generate_placeholder(query, context); assert!(answer.contains("deployment")); assert!(answer.contains("placeholder")); } #[test] fn test_context_summary_formatting() { let client = LlmClient { api_key: String::new(), model: "claude-opus-4-1".to_string(), base_url: "https://api.anthropic.com/v1".to_string(), }; let deployment_query = "How do I deploy?"; let context = "Line 1\nLine 2\nLine 3"; let summary = client.format_context_summary(deployment_query, context); assert!(summary.contains("deployment")); assert!(summary.contains("3")); } }