230 lines
7.3 KiB
Rust
230 lines
7.3 KiB
Rust
|
|
//! 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<ClaudeContent>,
|
||
|
|
pub stop_reason: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Claude response content
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct ClaudeContent {
|
||
|
|
#[serde(rename = "type")]
|
||
|
|
pub content_type: String,
|
||
|
|
pub text: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<Self> {
|
||
|
|
// 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<String> {
|
||
|
|
// 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<String> {
|
||
|
|
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"));
|
||
|
|
}
|
||
|
|
}
|