diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 142d4f2e..47414d4f 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -204,15 +204,19 @@ pub async fn chat_message( } }; - // Load conversation history (use and_then to keep stmt lifetime within closure) + // Load conversation history across ALL conversations for this issue 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", + "SELECT am.role, am.content \ + FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = ?1 \ + ORDER BY am.created_at ASC", ) .and_then(|mut stmt| { - stmt.query_map([&conversation_id], |row| { + stmt.query_map([&issue_id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map(|rows| rows.filter_map(|r| r.ok()).collect::>()) @@ -1025,4 +1029,194 @@ mod tests { let list = extract_list(text, "KEY_FINDINGS:"); assert_eq!(list, vec!["Actual item"]); } + + #[test] + fn test_history_query_same_conversation() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["issue-1", "Test", &now, &now], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-1", "conv-1", "user", "Hello", 5, "2025-01-01 10:00:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-2", "conv-1", "assistant", "Hi there", 8, "2025-01-01 10:00:01"], + ) + .unwrap(); + + let issue_id = "issue-1"; + let raw: Vec<(String, String)> = conn + .prepare( + "SELECT am.role, am.content \ + FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = ?1 \ + ORDER BY am.created_at ASC", + ) + .and_then(|mut stmt| { + stmt.query_map([&issue_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map(|rows| rows.filter_map(|r| r.ok()).collect::>()) + }) + .unwrap(); + + assert_eq!(raw.len(), 2); + assert_eq!(raw[0], ("user".to_string(), "Hello".to_string())); + assert_eq!(raw[1], ("assistant".to_string(), "Hi there".to_string())); + } + + #[test] + fn test_history_query_across_conversations() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["issue-1", "Test", &now, &now], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-1", "conv-1", "user", "From conv 1", 5, "2025-01-01 10:00:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-2", "conv-2", "user", "From conv 2", 5, "2025-01-01 11:00:00"], + ) + .unwrap(); + + let issue_id = "issue-1"; + let raw: Vec<(String, String)> = conn + .prepare( + "SELECT am.role, am.content \ + FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = ?1 \ + ORDER BY am.created_at ASC", + ) + .and_then(|mut stmt| { + stmt.query_map([&issue_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map(|rows| rows.filter_map(|r| r.ok()).collect::>()) + }) + .unwrap(); + + assert_eq!(raw.len(), 2); + assert_eq!(raw[0].1, "From conv 1"); + assert_eq!(raw[1].1, "From conv 2"); + } + + #[test] + fn test_history_query_empty_for_new_issue() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["issue-new", "Empty", &now, &now], + ) + .unwrap(); + + let issue_id = "issue-new"; + let raw: Vec<(String, String)> = conn + .prepare( + "SELECT am.role, am.content \ + FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = ?1 \ + ORDER BY am.created_at ASC", + ) + .and_then(|mut stmt| { + stmt.query_map([&issue_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map(|rows| rows.filter_map(|r| r.ok()).collect::>()) + }) + .unwrap(); + + assert!(raw.is_empty()); + } + + #[test] + fn test_history_query_ordered_by_created_at() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["issue-1", "Test", &now, &now], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"], + ) + .unwrap(); + // Insert messages out of order: conv-2 message is earlier than conv-1 message + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-1", "conv-1", "user", "Second", 5, "2025-01-01 12:00:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-2", "conv-2", "user", "First", 5, "2025-01-01 09:00:00"], + ) + .unwrap(); + + let issue_id = "issue-1"; + let raw: Vec<(String, String)> = conn + .prepare( + "SELECT am.role, am.content \ + FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = ?1 \ + ORDER BY am.created_at ASC", + ) + .and_then(|mut stmt| { + stmt.query_map([&issue_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map(|rows| rows.filter_map(|r| r.ok()).collect::>()) + }) + .unwrap(); + + assert_eq!(raw.len(), 2); + assert_eq!(raw[0].1, "First"); + assert_eq!(raw[1].1, "Second"); + } } diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 73550f94..9746dc32 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -347,7 +347,7 @@ pub async fn list_issues( let offset = filter.offset.unwrap_or(0); let mut sql = String::from( - "SELECT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \ + "SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \ (SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \ (SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \ FROM issues i WHERE 1=1", @@ -384,9 +384,19 @@ pub async fn list_issues( } if let Some(ref search) = filter.search { let pattern = format!("%{search}%"); + let idx = params.len() + 1; sql.push_str(&format!( - " AND (i.title LIKE ?{0} OR i.description LIKE ?{0} OR i.category LIKE ?{0})", - params.len() + 1 + " AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \ + OR EXISTS (SELECT 1 FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \ + OR EXISTS (SELECT 1 FROM resolution_steps rs \ + WHERE rs.issue_id = i.id \ + AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \ + OR EXISTS (SELECT 1 FROM log_files lf \ + WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \ + OR EXISTS (SELECT 1 FROM timeline_events te \ + WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))", )); params.push(Box::new(pattern)); } @@ -635,3 +645,163 @@ pub async fn get_timeline_events( .collect(); Ok(events) } + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + conn + } + + fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \ + VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')", + rusqlite::params![id, title, description, &now, &now], + ) + .unwrap(); + } + + fn run_search_query(conn: &Connection, search: &str) -> Vec { + let pattern = format!("%{search}%"); + let idx = 1; + let sql = format!( + "SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \ + (SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \ + (SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \ + FROM issues i WHERE 1=1 \ + AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \ + OR EXISTS (SELECT 1 FROM ai_messages am \ + JOIN ai_conversations ac ON ac.id = am.conversation_id \ + WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \ + OR EXISTS (SELECT 1 FROM resolution_steps rs \ + WHERE rs.issue_id = i.id \ + AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \ + OR EXISTS (SELECT 1 FROM log_files lf \ + WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \ + OR EXISTS (SELECT 1 FROM timeline_events te \ + WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \ + ORDER BY i.updated_at DESC" + ); + let mut stmt = conn.prepare(&sql).unwrap(); + stmt.query_map([&pattern], |row| row.get::<_, String>(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } + + #[test] + fn test_search_finds_by_title() { + let conn = setup_test_db(); + insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details"); + insert_issue(&conn, "issue-2", "Network timeout", "No details"); + + let results = run_search_query(&conn, "Kubernetes"); + assert_eq!(results, vec!["issue-1"]); + } + + #[test] + fn test_search_finds_by_description() { + let conn = setup_test_db(); + insert_issue( + &conn, + "issue-1", + "Generic title", + "The pod was killed due to memory pressure", + ); + insert_issue(&conn, "issue-2", "Other", "All fine"); + + let results = run_search_query(&conn, "memory pressure"); + assert_eq!(results, vec!["issue-1"]); + } + + #[test] + fn test_search_finds_by_ai_message_content() { + let conn = setup_test_db(); + insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"], + ).unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now], + ).unwrap(); + + let results = run_search_query(&conn, "deadlock in PostgreSQL"); + assert_eq!(results, vec!["issue-1"]); + } + + #[test] + fn test_search_finds_by_resolution_step_answer() { + let conn = setup_test_db(); + insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now], + ).unwrap(); + + let results = run_search_query(&conn, "pool exhausted"); + assert_eq!(results, vec!["issue-1"]); + } + + #[test] + fn test_search_finds_by_log_file_name() { + let conn = setup_test_db(); + insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0], + ).unwrap(); + + let results = run_search_query(&conn, "nginx-error"); + assert_eq!(results, vec!["issue-1"]); + } + + #[test] + fn test_search_finds_by_timeline_description() { + let conn = setup_test_db(); + insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); + conn.execute( + "INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"], + ).unwrap(); + + let results = run_search_query(&conn, "DNS resolution"); + assert_eq!(results, vec!["issue-1"]); + } + + #[test] + fn test_search_no_duplicates_for_multiple_matches() { + let conn = setup_test_db(); + insert_issue( + &conn, + "issue-1", + "Kubernetes crash", + "Kubernetes pod killed", + ); + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"], + ).unwrap(); + conn.execute( + "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now], + ).unwrap(); + + let results = run_search_query(&conn, "Kubernetes"); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "issue-1"); + } +}