diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b8d10f1..25e7b21 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1636,229 +1636,263 @@ pub async fn phomy_ask(question: String, state: State<'_, AppState>) -> Result = transcript + // ---- Intent Routing ---- + match intent.intent.as_str() { + "RECENCY" => handle_recency_intent(&question, &state, &client).await, + "TIME_WINDOW" => { + let mins = intent.time_minutes.unwrap_or(5); + handle_time_window_intent(&question, mins, &state, &client).await + } + "RECALL_LAST" => handle_recall_last_intent(&question, &state, &client).await, + "GLOBAL_SUMMARY" => handle_global_summary_intent(&question, &state, &client).await, + "ACTION_ITEMS" => handle_action_items_intent(&question, &state, &client).await, + _ => handle_specific_query_intent(&question, &state, &client).await, + } +} + +// ============================================================================ +// Phomy Intent Handlers +// ============================================================================ + +async fn handle_recency_intent( + question: &str, + state: &State<'_, AppState>, + client: &LlmClient, +) -> Result { + let context = { + let is_recording = *state.is_recording.lock().await; + let active_mid = state.active_meeting_id.lock().await.clone(); + + if is_recording { + let transcript = state.transcript.lock().await; + let recent: Vec<_> = transcript + .iter() + .rev() + .take(10) + .collect::>() + .into_iter() + .rev() + .collect(); + recent + .iter() + .map(|s| format!("[{}] {}", s.time, s.text)) + .collect::>() + .join("\n") + } else if let Some(mid) = active_mid { + let segs = state + .db + .get_last_segments(&mid, 10) + .map_err(|e| format!("DB error: {}", e))?; + segs.iter() + .map(|s| { + let text = s.enhanced_text.as_ref().unwrap_or(&s.text); + format!("[{}] {}", s.time_label, text) + }) + .collect::>() + .join("\n") + } else { + return Err("No active meeting to reference.".to_string()); + } + }; + + if context.is_empty() { + return Err("No recent transcript available.".to_string()); + } + + let system = "You are Phomy, a calm meeting assistant. Summarize what was just said based on the most recent transcript chunks. Be brief and direct."; + let user = format!("Recent transcript:\n{}\n\nQuestion: {}", context, question); + client + .complete(system, &user) + .await + .map_err(|e| format!("LLM error: {}", e)) +} + +async fn handle_time_window_intent( + question: &str, + mins: i64, + state: &State<'_, AppState>, + client: &LlmClient, +) -> Result { + let context = { + let is_recording = *state.is_recording.lock().await; + let active_mid = state.active_meeting_id.lock().await.clone(); + + if is_recording { + let transcript = state.transcript.lock().await; + if let Some(latest) = transcript.last() { + let cutoff = (latest.timestamp_ms as i64) - (mins * 60 * 1000); + let filtered: Vec<_> = transcript .iter() - .rev() - .take(10) - .collect::>() - .into_iter() - .rev() + .filter(|s| s.timestamp_ms as i64 >= cutoff) .collect(); - recent + filtered .iter() .map(|s| format!("[{}] {}", s.time, s.text)) .collect::>() .join("\n") - } else if let Some(mid) = active_mid { - let segs = state - .db - .get_last_segments(&mid, 10) - .map_err(|e| format!("DB error: {}", e))?; - // Use enhanced text when available - segs.iter() - .map(|s| { - let text = s.enhanced_text.as_ref().unwrap_or(&s.text); - format!("[{}] {}", s.time_label, text) - }) + } else { + String::new() + } + } else if let Some(mid) = active_mid { + let segs = state + .db + .get_segments(&mid) + .map_err(|e| format!("DB error: {}", e))?; + if let Some(latest) = segs.last() { + let cutoff = latest.timestamp_ms - (mins * 60 * 1000); + let filtered: Vec<_> = + segs.iter().filter(|s| s.timestamp_ms >= cutoff).collect(); + filtered + .iter() + .map(|s| format!("[{}] {}", s.time_label, s.text)) .collect::>() .join("\n") } else { - return Err("No active meeting to reference.".to_string()); + String::new() } - }; - - if context.is_empty() { - return Err("No recent transcript available.".to_string()); + } else { + return Err("No active meeting to reference.".to_string()); } + }; - let system = "You are Phomy, a calm meeting assistant. Summarize what was just said based on the most recent transcript chunks. Be brief and direct."; - let user = format!("Recent transcript:\n{}\n\nQuestion: {}", context, question); - return client - .complete(system, &user) - .await - .map_err(|e| format!("LLM error: {}", e)); + if context.is_empty() { + return Err("No transcript in that time range.".to_string()); } - // ---- Route 2: Time-based queries ("last 5 minutes", "past 10 minutes") ---- - if intent.intent == "TIME_WINDOW" { - let mins = intent.time_minutes.unwrap_or(5); // Default to 5 if LLM fails to extract - let context = { - let is_recording = *state.is_recording.lock().await; - let active_mid = state.active_meeting_id.lock().await.clone(); - - if is_recording { - let transcript = state.transcript.lock().await; - if let Some(latest) = transcript.last() { - let cutoff = (latest.timestamp_ms as i64) - (mins * 60 * 1000); - let filtered: Vec<_> = transcript - .iter() - .filter(|s| s.timestamp_ms as i64 >= cutoff) - .collect(); - filtered - .iter() - .map(|s| format!("[{}] {}", s.time, s.text)) - .collect::>() - .join("\n") - } else { - String::new() - } - } else if let Some(mid) = active_mid { - let segs = state - .db - .get_segments(&mid) - .map_err(|e| format!("DB error: {}", e))?; - if let Some(latest) = segs.last() { - let cutoff = latest.timestamp_ms - (mins * 60 * 1000); - let filtered: Vec<_> = - segs.iter().filter(|s| s.timestamp_ms >= cutoff).collect(); - filtered - .iter() - .map(|s| format!("[{}] {}", s.time_label, s.text)) - .collect::>() - .join("\n") - } else { - String::new() - } - } else { - return Err("No active meeting to reference.".to_string()); - } - }; + let system = "You are Phomy, a calm meeting assistant. Summarize the transcript from the requested time window. Be concise."; + let user = format!( + "Transcript from the last {} minutes:\n{}\n\nQuestion: {}", + mins, context, question + ); + client + .complete(system, &user) + .await + .map_err(|e| format!("LLM error: {}", e)) +} - if context.is_empty() { - return Err("No transcript in that time range.".to_string()); - } +async fn handle_recall_last_intent( + question: &str, + state: &State<'_, AppState>, + client: &LlmClient, +) -> Result { + let meetings = state + .db + .get_recent_meetings_with_summaries(1) + .map_err(|e| format!("DB error: {}", e))?; - let system = "You are Phomy, a calm meeting assistant. Summarize the transcript from the requested time window. Be concise."; - let user = format!( - "Transcript from the last {} minutes:\n{}\n\nQuestion: {}", - mins, context, question - ); - return client - .complete(system, &user) - .await - .map_err(|e| format!("LLM error: {}", e)); + if meetings.is_empty() { + return Err("No completed meetings found.".to_string()); } - // ---- Route 3: Meeting recall ("last meeting", "previous meeting") ---- - if intent.intent == "RECALL_LAST" { - let meetings = state + let (mid, title, created_at, summary) = &meetings[0]; + let context = if let Some(s) = summary { + format!("Meeting: {} ({})\nSummary:\n{}", title, created_at, s) + } else { + let segs = state .db - .get_recent_meetings_with_summaries(1) + .get_segments(mid) .map_err(|e| format!("DB error: {}", e))?; + let transcript: String = segs + .iter() + .map(|s| format!("[{}] {}", s.time_label, s.text)) + .collect::>() + .join("\n"); + format!( + "Meeting: {} ({})\nTranscript:\n{}", + title, created_at, transcript + ) + }; - if meetings.is_empty() { - return Err("No completed meetings found.".to_string()); - } + let system = "You are Phomy, a calm meeting assistant. Answer the question using the meeting context provided. Be helpful and concise."; + let user = format!("{}\n\nQuestion: {}", context, question); + client + .complete(system, &user) + .await + .map_err(|e| format!("LLM error: {}", e)) +} - let (mid, title, created_at, summary) = &meetings[0]; - let context = if let Some(s) = summary { - format!("Meeting: {} ({})\nSummary:\n{}", title, created_at, s) - } else { - // Fall back to transcript chunks - let segs = state - .db - .get_segments(mid) - .map_err(|e| format!("DB error: {}", e))?; - let transcript: String = segs - .iter() - .map(|s| format!("[{}] {}", s.time_label, s.text)) - .collect::>() - .join("\n"); - format!( - "Meeting: {} ({})\nTranscript:\n{}", - title, created_at, transcript - ) - }; +async fn handle_global_summary_intent( + question: &str, + state: &State<'_, AppState>, + client: &LlmClient, +) -> Result { + let meetings = state + .db + .get_recent_meetings_with_summaries(10) + .map_err(|e| format!("DB error: {}", e))?; - let system = "You are Phomy, a calm meeting assistant. Answer the question using the meeting context provided. Be helpful and concise."; - let user = format!("{}\n\nQuestion: {}", context, question); - return client - .complete(system, &user) - .await - .map_err(|e| format!("LLM error: {}", e)); + if meetings.is_empty() { + return Err("No completed meetings found.".to_string()); } - // ---- Route 4: Global/weekly summaries ("this week", "all meetings", "overall") ---- - if intent.intent == "GLOBAL_SUMMARY" { - let meetings = state - .db - .get_recent_meetings_with_summaries(10) - .map_err(|e| format!("DB error: {}", e))?; + let context: String = meetings + .iter() + .map(|(_, title, created_at, summary)| { + if let Some(s) = summary { + format!("--- {} ({}) ---\n{}\n", title, created_at, s) + } else { + format!( + "--- {} ({}) ---\n(no summary available)\n", + title, created_at + ) + } + }) + .collect::>() + .join("\n"); - if meetings.is_empty() { - return Err("No completed meetings found.".to_string()); - } + let system = "You are Phomy, a calm meeting assistant. Provide a high-level overview across the meetings described. Be concise and organized."; + let user = format!("Meeting summaries:\n{}\n\nQuestion: {}", context, question); + client + .complete(system, &user) + .await + .map_err(|e| format!("LLM error: {}", e)) +} - let context: String = meetings - .iter() - .map(|(_, title, created_at, summary)| { - if let Some(s) = summary { - format!("--- {} ({}) ---\n{}\n", title, created_at, s) - } else { - format!( - "--- {} ({}) ---\n(no summary available)\n", - title, created_at - ) - } - }) - .collect::>() - .join("\n"); +async fn handle_action_items_intent( + question: &str, + state: &State<'_, AppState>, + client: &LlmClient, +) -> Result { + let meetings = state + .db + .get_recent_meetings_with_summaries(10) + .map_err(|e| format!("DB error: {}", e))?; - let system = "You are Phomy, a calm meeting assistant. Provide a high-level overview across the meetings described. Be concise and organized."; - let user = format!("Meeting summaries:\n{}\n\nQuestion: {}", context, question); - return client - .complete(system, &user) - .await - .map_err(|e| format!("LLM error: {}", e)); + if meetings.is_empty() { + return Err("No completed meetings found to check for action items.".to_string()); } - // ---- Route 5 (NEW): Action Items ---- - if intent.intent == "ACTION_ITEMS" { - let meetings = state - .db - .get_recent_meetings_with_summaries(10) - .map_err(|e| format!("DB error: {}", e))?; - - if meetings.is_empty() { - return Err("No completed meetings found to check for action items.".to_string()); - } - - let context: String = meetings - .iter() - .map(|(_, title, created_at, summary)| { - if let Some(s) = summary { - format!("--- {} ({}) ---\n{}\n", title, created_at, s) - } else { - String::new() - } - }) - .collect::>() - .join("\n"); + let context: String = meetings + .iter() + .map(|(_, title, created_at, summary)| { + if let Some(s) = summary { + format!("--- {} ({}) ---\n{}\n", title, created_at, s) + } else { + String::new() + } + }) + .collect::>() + .join("\n"); - let system = "You are Phomy, a helpful meeting assistant. The user is asking about their action items, tasks, or to-dos. Extract and list all relevant action items from the provided meeting summaries. Be organized and concise."; - let user = format!("Meeting summaries:\n{}\n\nQuestion: {}", context, question); - return client - .complete(system, &user) - .await - .map_err(|e| format!("LLM error: {}", e)); - } + let system = "You are Phomy, a helpful meeting assistant. The user is asking about their action items, tasks, or to-dos. Extract and list all relevant action items from the provided meeting summaries. Be organized and concise."; + let user = format!("Meeting summaries:\n{}\n\nQuestion: {}", context, question); + client + .complete(system, &user) + .await + .map_err(|e| format!("LLM error: {}", e)) +} - // ---- Default: semantic search across all meetings (SPECIFIC_QUERY) ---- +async fn handle_specific_query_intent( + question: &str, + state: &State<'_, AppState>, + client: &LlmClient, +) -> Result { let limit = 10; let semantic_context: Option = { let query_emb = { let model_guard = state.embedding_model.lock().await; match model_guard.as_ref() { - Some(model) => model.embed(&question).ok(), + Some(model) => model.embed(question).ok(), None => None, } }; @@ -3286,6 +3320,7 @@ pub async fn phomy_ask_with_search( question: String, use_web_search: bool, state: State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { // First try to answer from meeting data let answer = phomy_ask(question.clone(), state.clone()).await; @@ -3295,9 +3330,23 @@ pub async fn phomy_ask_with_search( && (answer.is_err() || answer .as_ref() - .map(|a| a.contains("No meeting")) + .map(|a| { + let lower = a.to_lowercase(); + lower.contains("no meeting") + || lower.contains("not in the context") + || lower.contains("does not include information") + || lower.contains("does not contain any information") + || lower.contains("not mentioned") + || lower.contains("no information") + || lower.contains("cannot answer") + || lower.contains("not provide information") + || lower.contains("no_context_found") + }) .unwrap_or(false)) { + // Tell the frontend a web search has started so it can show a loading indicator + let _ = app.emit("phomy-web-search-started", ()); + // Perform web search let search_results = web_search(question.clone()).await?; diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 6824a17..4ba2e90 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -182,7 +182,7 @@ impl LlmClient { /// Answer a question about the meeting pub async fn answer_question(&self, context: &str, question: &str) -> Result { let system = "You are a helpful assistant answering questions about a meeting. \ - Use only the provided context to answer. If the answer isn't in the context, say so."; + Use only the provided context to answer. If the answer isn't in the context, explicitly say NO_CONTEXT_FOUND."; let user = format!("Context:\n{}\n\nQuestion: {}", context, question); self.complete(system, &user).await } @@ -417,15 +417,29 @@ Do not include markdown blocks, just the raw JSON."#; let result = self.complete(system, &user).await?; // Extract JSON - let json_str = result.trim() + let json_str = result.trim(); + let json_str = json_str .trim_start_matches("```json") .trim_start_matches("```") .trim_end_matches("```") .trim(); - // Try to parse direct - serde_json::from_str::(json_str) - .map_err(|e| anyhow!("Failed to parse intent JSON: {} - raw: {}", e, json_str)) + // Try direct parse first + if let Ok(intent) = serde_json::from_str::(json_str) { + return Ok(intent); + } + + // Try to extract JSON object from response if there's surrounding text + if let Some(start) = json_str.find('{') { + if let Some(end) = json_str.rfind('}') { + let json = &json_str[start..=end]; + if let Ok(intent) = serde_json::from_str::(json) { + return Ok(intent); + } + } + } + + Err(anyhow!("Failed to parse intent JSON from raw: {}", json_str)) } } @@ -449,8 +463,29 @@ pub struct MeetingMetadata { } /// Phomy recognized query intent -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct PhomyIntent { pub intent: String, pub time_minutes: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_phomy_intent_success() { + let raw_json = r#"{"intent": "RECENCY", "time_minutes": null}"#; + let intent: PhomyIntent = serde_json::from_str(raw_json).unwrap(); + assert_eq!(intent.intent, "RECENCY"); + assert_eq!(intent.time_minutes, None); + } + + #[test] + fn test_parse_phomy_intent_time_window() { + let raw_json = r#"{"intent": "TIME_WINDOW", "time_minutes": 15}"#; + let intent: PhomyIntent = serde_json::from_str(raw_json).unwrap(); + assert_eq!(intent.intent, "TIME_WINDOW"); + assert_eq!(intent.time_minutes, Some(15)); + } +} diff --git a/src-tauri/src/specs/mod.rs b/src-tauri/src/specs/mod.rs index fa44852..579af0e 100644 --- a/src-tauri/src/specs/mod.rs +++ b/src-tauri/src/specs/mod.rs @@ -237,6 +237,6 @@ mod tests { os: "Test OS".to_string(), }; let rec = ModelRecommendation::from_specs(&specs); - assert_eq!(rec.recommended_model, "medium"); + assert_eq!(rec.recommended_model, "large"); } } diff --git a/src-tauri/src/websearch/mod.rs b/src-tauri/src/websearch/mod.rs index 98645de..59106fd 100644 --- a/src-tauri/src/websearch/mod.rs +++ b/src-tauri/src/websearch/mod.rs @@ -48,7 +48,7 @@ impl WebSearchClient { self.parse_results(&html, max_results) } - /// Parse DuckDuckGo HTML results - simplified version + /// Parse DuckDuckGo HTML results - unwraps redirect URLs fn parse_results(&self, html: &str, max_results: usize) -> Result> { let mut results = Vec::new(); @@ -62,18 +62,34 @@ impl WebSearchClient { break; } - let url = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + let mut url = cap.get(1).map(|m| m.as_str()).unwrap_or("").to_string(); let title = cap.get(2).map(|m| m.as_str()).unwrap_or(""); - // Skip DuckDuckGo internal URLs + // Attempt to unwrap duckduckgo redirect URLs + if url.contains("duckduckgo.com/l/?uddg=") { + if let Some(start_idx) = url.find("uddg=") { + let encoded_url = &url[start_idx + 5..]; + if let Some(end_idx) = encoded_url.find('&') { + url = urlencoding::decode(&encoded_url[..end_idx]) + .map(|cow| cow.into_owned()) + .unwrap_or_else(|_| url); + } else { + url = urlencoding::decode(encoded_url) + .map(|cow| cow.into_owned()) + .unwrap_or_else(|_| url); + } + } + } + + // Skip invalid or internal URLs if url.contains("duckduckgo.com") || url.is_empty() { continue; } results.push(SearchResult { title: title.to_string(), - url: url.to_string(), - snippet: String::new(), + url, + snippet: String::new(), // You can also extract snippets if needed }); } @@ -86,3 +102,21 @@ impl Default for WebSearchClient { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duckduckgo_redirect() { + let client = WebSearchClient::new(); + let html = r#" + Speedtest by Ookla + "#; + + let results = client.parse_results(html, 1).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Speedtest by Ookla"); + assert_eq!(results[0].url, "https://www.speedtest.net/"); + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 40836e8..dd3c37e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -890,6 +890,16 @@ askQuestion(); } + let phomyWebSearchLoading = $state(false); + let phomyWebSearchCancelled = $state(false); + + function cancelWebSearch() { + phomyWebSearchCancelled = true; + phomyWebSearchLoading = false; + phomyIsAsking = false; + phomyHistory = [...phomyHistory.slice(0, -1), { role: 'assistant', text: "Web search cancelled by user." }]; + } + async function askPhomy() { if (!phomyQuestion.trim() || phomyIsAsking) return; const q = phomyQuestion.trim(); @@ -898,16 +908,35 @@ phomyAnswer = ""; phomyReferences = []; phomyContextLimit = 10; + phomyWebSearchLoading = false; + phomyWebSearchCancelled = false; phomyHistory = [...phomyHistory, { role: 'user', text: q }]; + // Listen for Web Search Triggered Event + const unlistenSearchStarted = await listen("phomy-web-search-started", () => { + if (!phomyWebSearchCancelled) { + phomyWebSearchLoading = true; + } + }); + try { // Semantic search for references (display only) const refs = await meetingsStore.semanticSearch(q, undefined, 10); phomyReferences = refs; // Use Phomy to answer questions (web search fallback is built-in) - const ans = await invoke("phomy_ask", { question: q }); + const ansPromise = invoke("phomy_ask_with_search", { question: q, useWebSearch: true }); + + const ans = await ansPromise; + + // If user clicked cancel during the prolonged web-search, discard the result + if (phomyWebSearchCancelled) { + unlistenSearchStarted(); + return; + } + + phomyWebSearchLoading = false; phomyAnswer = ans; phomyHistory = [...phomyHistory, { role: 'assistant', text: ans, refs }]; @@ -918,6 +947,11 @@ } }, 50); } catch (e) { + if (phomyWebSearchCancelled) { + unlistenSearchStarted(); + return; + } + phomyWebSearchLoading = false; const errMsg = `Error: ${e}`; phomyAnswer = errMsg; phomyHistory = [...phomyHistory, { role: 'assistant', text: errMsg }]; @@ -928,8 +962,12 @@ phomyChatContainer.scrollTop = phomyChatContainer.scrollHeight; } }, 50); + } finally { + unlistenSearchStarted(); + if (!phomyWebSearchCancelled) { + phomyIsAsking = false; + } } - phomyIsAsking = false; } function toggleRefs(index: number) { @@ -954,7 +992,7 @@ const refs = await meetingsStore.semanticSearch(lastUserMsg.text, undefined, newLimit); phomyReferences = refs; - const ans = await invoke("phomy_ask", { question: lastUserMsg.text }); + const ans = await invoke("phomy_ask_with_search", { question: lastUserMsg.text, useWebSearch: true }); phomyAnswer = ans; phomyHistory = [ ...phomyHistory.slice(0, -1), @@ -2006,7 +2044,28 @@ {/if} -
+
+ {#if phomyWebSearchLoading} +
+
+ + + + +
+ Searching the web... + No context found in meeting. +
+
+ +
+ {/if} +
{ e.preventDefault(); @@ -2018,13 +2077,13 @@ type="text" bind:value={phomyQuestion} placeholder={embeddingModelLoaded ? "Ask Phomy about your meetings..." : "Loading embedding model..."} - disabled={!embeddingModelLoaded} + disabled={!embeddingModelLoaded || phomyWebSearchLoading} class="w-full pl-4 pr-14 py-3.5 glass border border-phantom-ear-border rounded-2xl text-sm text-phantom-ear-text placeholder:text-phantom-ear-text-muted focus:outline-none focus:border-phantom-ear-purple/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed" />