use rusqlite::OptionalExtension; use tauri::{Manager, State}; use tracing::warn; use crate::ai::provider::create_provider; use crate::ai::{AnalysisResult, ChatResponse, Message, ProviderInfo}; use crate::db::models::{AiConversation, AiMessage, AuditEntry}; use crate::state::{AppState, ProviderConfig}; #[tauri::command] pub async fn analyze_logs( issue_id: String, log_file_ids: Vec, provider_config: ProviderConfig, state: State<'_, AppState>, ) -> Result { // Load log file contents — only redacted files may be sent to an AI provider let mut log_contents = String::new(); { let db = state.db.lock().map_err(|e| e.to_string())?; for file_id in &log_file_ids { let mut stmt = db .prepare("SELECT file_name, file_path, redacted FROM log_files WHERE id = ?1") .map_err(|e| e.to_string())?; if let Ok((name, path, redacted)) = stmt.query_row([file_id], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i32>(2)? != 0, )) }) { let redacted_path = redacted_path_for(&name, &path, redacted)?; log_contents.push_str(&format!("--- {name} ---\n")); if let Ok(content) = std::fs::read_to_string(&redacted_path) { log_contents.push_str(&content); } else { log_contents.push_str("[Could not read redacted file]\n"); } log_contents.push('\n'); } } } let provider = create_provider(&provider_config); let messages = vec![ Message { role: "system".into(), content: "You are an expert IT engineer. Analyze the following log content and \ identify key issues, errors, and anomalies. Structure your response as: \ SUMMARY: (one paragraph), KEY_FINDINGS: (bullet list), \ FIRST_WHY: (initial why question for 5-whys analysis), \ SEVERITY: (critical/high/medium/low)" .into(), tool_call_id: None, tool_calls: None, }, Message { role: "user".into(), content: format!("Analyze logs for issue {issue_id}:\n\n{log_contents}"), tool_call_id: None, tool_calls: None, }, ]; let response = provider .chat(messages, &provider_config, None) .await .map_err(|e| { warn!(error = %e, "ai analyze_logs provider request failed"); "AI analysis request failed".to_string() })?; let content = &response.content; let summary = extract_section(content, "SUMMARY:").unwrap_or_else(|| { content .lines() .next() .unwrap_or("Analysis complete") .to_string() }); let key_findings = extract_list(content, "KEY_FINDINGS:"); let suggested_why1 = extract_section(content, "FIRST_WHY:") .unwrap_or_else(|| "Why did this issue occur?".to_string()); let severity_assessment = extract_section(content, "SEVERITY:").unwrap_or_else(|| "medium".to_string()); // Write audit entry { let db = state.db.lock().map_err(|e| e.to_string())?; let entry = AuditEntry::new( "ai_analyze_logs".to_string(), "issue".to_string(), issue_id.clone(), serde_json::json!({ "log_file_ids": log_file_ids, "provider": provider_config.name }) .to_string(), ); crate::audit::log::write_audit_event( &db, &entry.action, &entry.entity_type, &entry.entity_id, &entry.details, ) .map_err(|_| "Failed to write security audit entry".to_string())?; } Ok(AnalysisResult { summary, key_findings, suggested_why1, severity_assessment, }) } /// Returns the path to the `.redacted` file, or an error if the file has not been redacted. fn redacted_path_for(name: &str, path: &str, redacted: bool) -> Result { if !redacted { return Err(format!( "Log file '{name}' has not been scanned and redacted. \ Run PII detection and apply redactions before sending to AI." )); } Ok(format!("{path}.redacted")) } fn extract_section(text: &str, header: &str) -> Option { let start = text.find(header)?; let after = &text[start + header.len()..]; let end = after.find('\n').unwrap_or(after.len()); let section = after[..end].trim().to_string(); if section.is_empty() { None } else { Some(section) } } fn extract_list(text: &str, header: &str) -> Vec { let start = match text.find(header) { Some(s) => s + header.len(), None => return vec![], }; let section = &text[start..]; section .lines() .skip(1) .take_while(|l| { !l.chars() .next() .map(|c| c.is_uppercase() && l.contains(':')) .unwrap_or(false) }) .filter(|l| l.trim_start().starts_with('-') || l.trim_start().starts_with('*')) .map(|l| { l.trim_start_matches(|c: char| c == '-' || c == '*' || c.is_whitespace()) .to_string() }) .filter(|s| !s.is_empty()) .collect() } #[tauri::command] pub async fn chat_message( issue_id: String, message: String, provider_config: ProviderConfig, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // Find or create a conversation for this issue + provider let conversation_id = { let db = state.db.lock().map_err(|e| e.to_string())?; let existing: Option = db .prepare("SELECT id FROM ai_conversations WHERE issue_id = ?1 AND provider = ?2 AND model = ?3 ORDER BY created_at DESC LIMIT 1") .and_then(|mut stmt| { stmt.query_row( rusqlite::params![issue_id, provider_config.name, provider_config.model], |row| row.get(0), ) }) .ok(); match existing { Some(id) => id, None => { let conv = AiConversation::new( issue_id.clone(), provider_config.name.clone(), provider_config.model.clone(), ); db.execute( "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ conv.id, conv.issue_id, conv.provider, conv.model, conv.created_at, conv.title ], ) .map_err(|e| e.to_string())?; conv.id } } }; // Load conversation history (use and_then to keep stmt lifetime within closure) let history: Vec = { let db = state.db.lock().map_err(|e| e.to_string())?; let raw: Vec<(String, String)> = db .prepare( "SELECT role, content FROM ai_messages WHERE conversation_id = ?1 ORDER BY created_at ASC", ) .and_then(|mut stmt| { stmt.query_map([&conversation_id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map(|rows| rows.filter_map(|r| r.ok()).collect::>()) }) .unwrap_or_default(); drop(db); raw.into_iter() .map(|(role, content)| Message { role, content, tool_call_id: None, tool_calls: None, }) .collect() }; let provider = create_provider(&provider_config); // Search integration sources for relevant context let integration_context = search_integration_sources(&message, &app_handle, &state).await; let mut messages = history; // If we found integration content, add it to the conversation context if !integration_context.is_empty() { let context_message = Message { role: "system".into(), content: format!( "INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\ Instructions: The above content is from internal company documentation systems \ (Confluence, ServiceNow, Azure DevOps). \ \n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\ \n- If the documentation directly addresses the question → Use it and cite sources with URLs\ \n- If the documentation is tangentially related but doesn't answer the question → Briefly mention what internal docs exist, then provide a complete answer using general knowledge\ \n- If the documentation is completely unrelated → Ignore it and answer using general knowledge\ \n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations." ), tool_call_id: None, tool_calls: None, }; messages.push(context_message); } messages.push(Message { role: "user".into(), content: message.clone(), tool_call_id: None, tool_calls: None, }); // Get available tools let tools = Some(crate::ai::tools::get_available_tools()); // Tool-calling loop: keep calling until AI gives final answer let final_response; let max_iterations = 10; // Prevent infinite loops let mut iteration = 0; loop { iteration += 1; if iteration > max_iterations { return Err("Tool-calling loop exceeded maximum iterations".to_string()); } let response = provider .chat(messages.clone(), &provider_config, tools.clone()) .await .map_err(|e| { let error_msg = format!("AI provider request failed: {e}"); warn!("{}", error_msg); error_msg })?; // Check if AI wants to call tools if let Some(tool_calls) = &response.tool_calls { tracing::info!("AI requested {} tool call(s)", tool_calls.len()); // Execute each tool call for tool_call in tool_calls { tracing::info!("Executing tool: {}", tool_call.name); let tool_result = execute_tool_call(tool_call, &app_handle, &state).await; // Format result let result_content = match tool_result { Ok(result) => result, Err(e) => format!("Error executing tool: {e}"), }; // Add tool result as a message messages.push(Message { role: "tool".into(), content: result_content, tool_call_id: Some(tool_call.id.clone()), tool_calls: None, }); } // Continue loop to get AI's next response continue; } // No tool calls - this is the final answer final_response = response; break; } // Save both user message and response to DB { let db = state.db.lock().map_err(|e| e.to_string())?; let user_msg = AiMessage::new(conversation_id.clone(), "user".to_string(), message); let asst_msg = AiMessage::new( conversation_id, "assistant".to_string(), final_response.content.clone(), ); db.execute( "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ user_msg.id, user_msg.conversation_id, user_msg.role, user_msg.content, user_msg.token_count, user_msg.created_at ], ) .map_err(|e| e.to_string())?; db.execute( "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ asst_msg.id, asst_msg.conversation_id, asst_msg.role, asst_msg.content, asst_msg.token_count, asst_msg.created_at ], ) .map_err(|e| e.to_string())?; // Audit - capture full transmission details let audit_details = serde_json::json!({ "provider": provider_config.name, "model": provider_config.model, "api_url": provider_config.api_url, "user_message": user_msg.content, "response_preview": if final_response.content.len() > 200 { format!("{preview}...", preview = &final_response.content[..200]) } else { final_response.content.clone() }, "token_count": user_msg.token_count, }); let entry = AuditEntry::new( "ai_chat".to_string(), "issue".to_string(), issue_id, audit_details.to_string(), ); if let Err(err) = crate::audit::log::write_audit_event( &db, &entry.action, &entry.entity_type, &entry.entity_id, &entry.details, ) { warn!(error = %err, "failed to write ai_chat audit entry"); } } Ok(final_response) } #[tauri::command] pub async fn test_provider_connection( provider_config: ProviderConfig, ) -> Result { let provider = create_provider(&provider_config); let messages = vec![Message { role: "user".into(), content: "Reply with exactly: Troubleshooting and RCA Assistant connection test successful." .into(), tool_call_id: None, tool_calls: None, }]; provider .chat(messages, &provider_config, None) .await .map_err(|e| { warn!(error = %e, "ai test_provider_connection failed"); "Provider connection test failed".to_string() }) } #[tauri::command] pub async fn list_providers() -> Result, String> { Ok(vec![ ProviderInfo { name: "openai".to_string(), supports_streaming: true, models: vec!["gpt-4o".to_string(), "gpt-4o-mini".to_string()], }, ProviderInfo { name: "anthropic".to_string(), supports_streaming: true, models: vec![ "claude-3-5-sonnet-20241022".to_string(), "claude-3-haiku-20240307".to_string(), ], }, ProviderInfo { name: "gemini".to_string(), supports_streaming: false, models: vec!["gemini-1.5-pro".to_string(), "gemini-1.5-flash".to_string()], }, ProviderInfo { name: "mistral".to_string(), supports_streaming: true, models: vec![ "mistral-large-latest".to_string(), "mistral-small-latest".to_string(), ], }, ProviderInfo { name: "ollama".to_string(), supports_streaming: false, models: vec!["llama3.2:3b".to_string(), "llama3.1:8b".to_string()], }, ]) } /// Search integration sources (Confluence, ServiceNow, Azure DevOps) for relevant context async fn search_integration_sources( query: &str, app_handle: &tauri::AppHandle, state: &State<'_, AppState>, ) -> String { let mut all_results = Vec::new(); // Try to get integration configurations let configs: Vec = { let db = match state.db.lock() { Ok(db) => db, Err(e) => { tracing::warn!("Failed to lock database: {}", e); return String::new(); } }; let mut stmt = match db.prepare( "SELECT service, base_url, username, project_name, space_key FROM integration_config", ) { Ok(stmt) => stmt, Err(e) => { tracing::warn!("Failed to prepare statement: {}", e); return String::new(); } }; let rows = match stmt.query_map([], |row| { Ok(crate::commands::integrations::IntegrationConfig { service: row.get(0)?, base_url: row.get(1)?, username: row.get(2)?, project_name: row.get(3)?, space_key: row.get(4)?, }) }) { Ok(rows) => rows, Err(e) => { tracing::warn!("Failed to query integration configs: {}", e); return String::new(); } }; rows.filter_map(|r| r.ok()).collect() }; // Search each available integration in parallel let mut search_tasks = Vec::new(); for config in configs { // Authentication priority: // 1. Try cookies from persistent browser (may fail for HttpOnly) // 2. Try stored credentials from database // 3. Fall back to webview-based search (uses browser's session directly) let cookies_opt = match crate::commands::integrations::get_fresh_cookies_from_webview( &config.service, app_handle, state, ) .await { Ok(Some(cookies)) => { tracing::info!("Using extracted cookies for {}", config.service); Some(cookies) } _ => { // Fallback: check for stored credentials in database tracing::info!( "Cookie extraction failed for {}, checking stored credentials", config.service ); let encrypted_token: Option = { let db = match state.db.lock() { Ok(db) => db, Err(_) => continue, }; db.query_row( "SELECT encrypted_token FROM credentials WHERE service = ?1", [&config.service], |row| row.get::<_, String>(0), ) .optional() .ok() .flatten() }; if let Some(token) = encrypted_token { if let Ok(decrypted) = crate::integrations::auth::decrypt_token(&token) { // Try to parse as cookies JSON if let Ok(cookie_list) = serde_json::from_str::< Vec, >(&decrypted) { tracing::info!( "Using stored cookies for {} (count: {})", config.service, cookie_list.len() ); Some(cookie_list) } else { tracing::warn!( "Stored credentials for {} not in cookie format", config.service ); None } } else { None } } else { None } } }; // If we have cookies (from extraction or database), use standard API search if let Some(cookies) = cookies_opt { match config.service.as_str() { "confluence" => { let base_url = config.base_url.clone(); let query = query.to_string(); let cookies_clone = cookies.clone(); search_tasks.push(tokio::spawn(async move { crate::integrations::confluence_search::search_confluence( &base_url, &query, &cookies_clone, ) .await .unwrap_or_default() })); } "servicenow" => { let instance_url = config.base_url.clone(); let query = query.to_string(); let cookies_clone = cookies.clone(); search_tasks.push(tokio::spawn(async move { let mut results = Vec::new(); // Search knowledge base if let Ok(kb_results) = crate::integrations::servicenow_search::search_servicenow( &instance_url, &query, &cookies_clone, ) .await { results.extend(kb_results); } // Search incidents if let Ok(incident_results) = crate::integrations::servicenow_search::search_incidents( &instance_url, &query, &cookies_clone, ) .await { results.extend(incident_results); } results })); } "azuredevops" => { let org_url = config.base_url.clone(); let project = config.project_name.unwrap_or_default(); let query = query.to_string(); let cookies_clone = cookies.clone(); search_tasks.push(tokio::spawn(async move { let mut results = Vec::new(); // Search wiki if let Ok(wiki_results) = crate::integrations::azuredevops_search::search_wiki( &org_url, &project, &query, &cookies_clone, ) .await { results.extend(wiki_results); } // Search work items if let Ok(wi_results) = crate::integrations::azuredevops_search::search_work_items( &org_url, &project, &query, &cookies_clone, ) .await { results.extend(wi_results); } results })); } _ => {} } } else { // Final fallback: try webview-based fetch (includes HttpOnly cookies automatically) // This makes HTTP requests FROM the authenticated webview, which includes all cookies tracing::info!( "No extracted cookies for {}, trying webview-based fetch", config.service ); // Check if webview exists for this service let webview_label = { let webviews = match state.integration_webviews.lock() { Ok(w) => w, Err(_) => continue, }; webviews.get(&config.service).cloned() }; if let Some(label) = webview_label { // Get window handle if let Some(webview_window) = app_handle.get_webview_window(&label) { let base_url = config.base_url.clone(); let service = config.service.clone(); let query_str = query.to_string(); match service.as_str() { "confluence" => { search_tasks.push(tokio::spawn(async move { tracing::info!("Executing Confluence search via webview fetch"); match crate::integrations::webview_fetch::search_confluence_webview( &webview_window, &base_url, &query_str, ) .await { Ok(results) => { tracing::info!( "Webview fetch for Confluence returned {} results", results.len() ); results } Err(e) => { tracing::warn!( "Webview fetch failed for Confluence: {}", e ); Vec::new() } } })); } "servicenow" => { search_tasks.push(tokio::spawn(async move { tracing::info!("Executing ServiceNow search via webview fetch"); match crate::integrations::webview_fetch::search_servicenow_webview( &webview_window, &base_url, &query_str, ) .await { Ok(results) => { tracing::info!( "Webview fetch for ServiceNow returned {} results", results.len() ); results } Err(e) => { tracing::warn!( "Webview fetch failed for ServiceNow: {}", e ); Vec::new() } } })); } "azuredevops" => { let project = config.project_name.unwrap_or_default(); search_tasks.push(tokio::spawn(async move { tracing::info!("Executing Azure DevOps search via webview fetch"); let mut results = Vec::new(); // Search wiki match crate::integrations::webview_fetch::search_azuredevops_wiki_webview( &webview_window, &base_url, &project, &query_str ).await { Ok(wiki_results) => { tracing::info!("Webview fetch for ADO wiki returned {} results", wiki_results.len()); results.extend(wiki_results); } Err(e) => { tracing::warn!("Webview fetch failed for ADO wiki: {}", e); } } // Search work items match crate::integrations::webview_fetch::search_azuredevops_workitems_webview( &webview_window, &base_url, &project, &query_str ).await { Ok(wi_results) => { tracing::info!("Webview fetch for ADO work items returned {} results", wi_results.len()); results.extend(wi_results); } Err(e) => { tracing::warn!("Webview fetch failed for ADO work items: {}", e); } } results })); } _ => {} } } else { tracing::warn!("Webview window not found for {}", config.service); } } else { tracing::warn!( "No webview open for {} - cannot search. Please open browser window in Settings → Integrations", config.service ); } } } // Wait for all searches to complete for task in search_tasks { if let Ok(results) = task.await { all_results.extend(results); } } // Format results for AI context if all_results.is_empty() { return String::new(); } let mut context = String::new(); for (idx, result) in all_results.iter().enumerate() { context.push_str(&format!("--- SOURCE {} ({}) ---\n", idx + 1, result.source)); context.push_str(&format!("Title: {}\n", result.title)); context.push_str(&format!("URL: {}\n", result.url)); if let Some(content) = &result.content { context.push_str(&format!("Content:\n{content}\n\n")); } else { context.push_str(&format!("Excerpt: {}\n\n", result.excerpt)); } } tracing::info!( "Found {} integration sources for AI context", all_results.len() ); context } /// Execute a tool call made by the AI async fn execute_tool_call( tool_call: &crate::ai::ToolCall, app_handle: &tauri::AppHandle, app_state: &State<'_, AppState>, ) -> Result { match tool_call.name.as_str() { "add_ado_comment" => { // Parse arguments let args: serde_json::Value = serde_json::from_str(&tool_call.arguments) .map_err(|e| format!("Failed to parse tool arguments: {e}"))?; let work_item_id = args .get("work_item_id") .and_then(|v| v.as_i64()) .ok_or_else(|| "Missing or invalid work_item_id parameter".to_string())?; let comment_text = args .get("comment_text") .and_then(|v| v.as_str()) .ok_or_else(|| "Missing or invalid comment_text parameter".to_string())?; // Execute the add_ado_comment command tracing::info!( "AI executing tool: add_ado_comment({}, \"{}\")", work_item_id, comment_text ); crate::commands::integrations::add_ado_comment( work_item_id, comment_text.to_string(), app_handle.clone(), app_state.clone(), ) .await } _ => { let error = format!("Unknown tool: {}", tool_call.name); tracing::warn!("{}", error); Err(error) } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_section_basic() { let text = "SUMMARY: The server crashed due to OOM.\nKEY_FINDINGS:\n- item"; assert_eq!( extract_section(text, "SUMMARY:"), Some("The server crashed due to OOM.".to_string()) ); } #[test] fn test_extract_section_missing() { let text = "No matching header here"; assert_eq!(extract_section(text, "SUMMARY:"), None); } #[test] fn test_extract_section_empty_value() { let text = "SUMMARY:\nKEY_FINDINGS:"; assert_eq!(extract_section(text, "SUMMARY:"), None); } #[test] fn test_extract_section_last_line() { let text = "SEVERITY: critical"; assert_eq!( extract_section(text, "SEVERITY:"), Some("critical".to_string()) ); } #[test] fn test_extract_list_basic() { let text = "KEY_FINDINGS:\n- First issue\n- Second issue\nSEVERITY: high"; let list = extract_list(text, "KEY_FINDINGS:"); assert_eq!(list, vec!["First issue", "Second issue"]); } #[test] fn test_extract_list_with_asterisks() { let text = "KEY_FINDINGS:\n* Item one\n* Item two\nFIRST_WHY: Why?"; let list = extract_list(text, "KEY_FINDINGS:"); assert_eq!(list, vec!["Item one", "Item two"]); } #[test] fn test_redacted_path_rejects_unredacted_file() { let err = redacted_path_for("app.log", "/data/app.log", false).unwrap_err(); assert!(err.contains("app.log")); assert!(err.contains("redacted")); } #[test] fn test_redacted_path_returns_dotredacted_suffix() { let path = redacted_path_for("app.log", "/data/app.log", true).unwrap(); assert_eq!(path, "/data/app.log.redacted"); } #[test] fn test_extract_list_missing_header() { let text = "No findings here"; let list = extract_list(text, "KEY_FINDINGS:"); assert!(list.is_empty()); } #[test] fn test_extract_list_empty_items_filtered() { let text = "KEY_FINDINGS:\n- \n- Actual item\n- \nNEXT:"; let list = extract_list(text, "KEY_FINDINGS:"); assert_eq!(list, vec!["Actual item"]); } }