diff --git a/docs/03-data-model.md b/docs/03-data-model.md index f4033c9..f47c865 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -234,7 +234,11 @@ label so re-diarization or renaming never requires rewriting every segment (FR-S "summary_md": "## Summary\n…", "decisions": ["Adopt Tauri for the shell"], "action_items": [ - { "text": "Send the API contract draft", "owner": "Jordan", "due": "2026-07-03" } + // Same shape as the `action_items` table row (ActionItem) minus the row + // never having existed yet: `id`/`due_at` are null until the user + // reviews and confirms a drafted item (FR-LLM-3), at which point + // `confirm_action_items` creates the real row. + { "id": null, "text": "Send the API contract draft", "owner": "Jordan", "due_at": null, "confirmed": false } ] } ``` diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index dd6d174..1d8eb48 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -39,7 +39,7 @@ map_speaker_to_participant(input: { meetingId: MeetingId; label: string; partici // ---- Meetings / storage ---- list_meetings(input: { query?: string; tag?: string; participantId?: string; limit?: number; offset?: number }): MeetingListItem[] -get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers +get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated) delete_meeting(input: { meetingId: MeetingId }): void export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string update_notes(input: { meetingId: MeetingId; markdown: string }): void @@ -53,6 +53,8 @@ llm_status(): { provider: string; reachable: boolean; isLocal: boolean; models: set_llm_provider(input: { provider: string; endpoint?: string; model?: string; apiKey?: string }): void // FR-AI-1/2 generate_summary(input: { meetingId: MeetingId; templateId?: string }): void // streams via events (FR-LLM-2/4) confirm_action_items(input: { meetingId: MeetingId; items: ActionItem[] }): void +llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggestedModel: { id: string; label: string; approxSizeGb: number } } // T5.7, FR-LLM-5 +pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7) // ---- Calendar / .pst ---- import_pst(input: { path: string; password?: string }): { eventsImported: number } // FR-CAL-1 @@ -112,7 +114,7 @@ privacy_self_check(): { "transcript://finalized" { meetingId, segmentCount } "diarization://updated" { meetingId, speakers: SpeakerInfo[] } // after post-pass (FR-SPK) "llm://token" { meetingId, text } // streamed summary (FR-LLM-4) -"llm://done" { meetingId, summary: SummaryRef } +"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer "model://progress" { id, receivedBytes, totalBytes } "pst://progress" { processed, total } "hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dc2dbbc..1c6051a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -15,7 +15,7 @@ use crate::paths::{ diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir, settings_path, wa_root, whisper_model_file, }; -use crate::storage::{FinalizeMeeting, Meeting, NewMeeting}; +use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile}; use crate::transcription::{ models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber, }; @@ -103,6 +103,13 @@ fn backend_for(settings: &Settings) -> BackendId { WinHardwareDetector.best(preferred).id } +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + /// Builds a `Diarizer` if both diarization models are installed — a fixed /// pair of well-known filenames, downloadable/removable via /// `diarization::models` and `list_diarization_models`/`download_model`/ @@ -1006,25 +1013,242 @@ pub async fn export_meeting( // ---- LLM (Phase 5) ---- +/// Builds the configured `LlmProvider`, or `None` if LLM integration is off +/// (default — the zero-egress state, FR-LLM-1). +fn llm_provider_from_settings(settings: &Settings) -> Option> { + match settings.llm_provider.as_str() { + "ollama" => Some(Box::new(crate::llm::OllamaProvider { + endpoint: settings.llm_endpoint.clone(), + model: settings.llm_model.clone(), + })), + "custom" => Some(Box::new(crate::llm::OpenAiCompatProvider { + endpoint: settings.llm_endpoint.clone(), + model: settings.llm_model.clone(), + credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways + })), + _ => None, // "off" + } +} + #[tauri::command] pub async fn llm_status() -> WaResult { - todo!("Phase 5 — llm_status") + let settings = load_settings(); + let Some(provider) = llm_provider_from_settings(&settings) else { + return Ok(serde_json::json!({ + "provider": "off", "reachable": false, "isLocal": true, "models": Vec::::new(), + })); + }; + let status = provider.status().await; + Ok(serde_json::json!({ + "provider": status.provider, + "reachable": status.reachable, + "isLocal": status.is_local, + "models": status.models, + })) } -/// Select/configure the LLM/AI provider. `apiKey` (hosted providers) goes to the OS credential -/// store, never settings/DB (Phase 10a, FR-AI-1/2). +#[derive(Deserialize)] +pub struct SetLlmProviderArgs { + pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai") + pub endpoint: Option, + pub model: Option, +} + +/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers +/// (`apiKey`, stored only in the OS credential store — never settings/DB) +/// land in Phase 10a. #[tauri::command] -pub async fn set_llm_provider(_config: serde_json::Value) -> WaResult<()> { - todo!("Phase 10a — set_llm_provider (ollama|custom|anthropic|openai|off)") +pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> { + if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") { + return Err(WaError::new( + "llm", + format!( + "provider '{}' is not available yet — hosted providers land in Phase 10a", + args.provider + ), + )); + } + let mut settings = load_settings(); + settings.llm_provider = args.provider; + if let Some(endpoint) = args.endpoint { + settings.llm_endpoint = endpoint; + } + if let Some(model) = args.model { + settings.llm_model = model; + } + save_settings(&settings) } +/// Detect a missing/unreachable provider and offer a hardware-aware model +/// suggestion (T5.7, FR-LLM-5). Read-only and on-demand — no background +/// polling (NFR-RES-1); the frontend calls this when Settings' LLM section +/// is open or `llm_status` comes back unreachable. +#[tauri::command] +pub async fn llm_setup_suggestions() -> WaResult { + let best_backend = WinHardwareDetector.best(None); + let suggestion = crate::llm::suggest_ollama_model(best_backend.vram_mb); + Ok(serde_json::json!({ + "ollamaInstalled": crate::llm::ollama_installed(), + "installUrl": "https://ollama.com/download/windows", + "suggestedModel": { + "id": suggestion.id, + "label": suggestion.label, + "approxSizeGb": suggestion.approx_size_gb, + }, + })) +} + +/// Guided model download via Ollama's `/api/pull` (T5.7, ADR-0007); streams +/// progress as `model://progress`, the same shape the whisper/diarization +/// catalogs use. +#[tauri::command] +pub async fn pull_ollama_model(app: AppHandle, model: String) -> WaResult<()> { + let endpoint = load_settings().llm_endpoint; + let model_for_progress = model.clone(); + crate::llm::pull_model(&endpoint, &model, move |received, total| { + let _ = app.emit( + "model://progress", + serde_json::json!({ "id": model_for_progress, "receivedBytes": received, "totalBytes": total }), + ); + }) + .await + .map_err(|e| WaError::new("llm", e.to_string())) +} + +/// Built-in prompt biases for common meeting shapes (T5.4). User-authored +/// templates are Phase 8's FR-NOTE-5 — an unrecognized id is ignored rather +/// than erroring, so a stale/removed template id never blocks a summary. +fn built_in_template(id: &str) -> Option<&'static str> { + match id { + "standup" => Some( + "This is a daily standup. Focus the summary on what each person did, what's \ + blocking them, and what's planned next.", + ), + "retro" => Some( + "This is a retrospective. Focus the summary on what went well, what didn't, and \ + concrete process changes to try.", + ), + "one-on-one" => Some( + "This is a 1:1. Focus the summary on the individual's updates, concerns, and \ + agreed next steps.", + ), + _ => None, + } +} + +/// Assembles the transcript + metadata (+ optional template) prompt (T5.4, +/// FR-LLM-2). Reuses `notes_markdown` (already speaker-tagged, kept in sync +/// by every rename/merge) as the transcript rather than re-rendering it. +fn build_prompt(meeting: &Meeting, template_id: Option<&str>) -> crate::llm::Prompt { + let participants: Vec = meeting + .speakers + .iter() + .map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone())) + .collect(); + let metadata = format!( + "Meeting: {}\nDuration: {} min\nParticipants: {}", + meeting.title, + meeting.duration_secs.unwrap_or(0) / 60, + if participants.is_empty() { + "unknown".to_string() + } else { + participants.join(", ") + }, + ); + crate::llm::Prompt { + transcript: meeting.notes_markdown.clone(), + metadata, + template: template_id.and_then(built_in_template).map(str::to_string), + } +} + +/// Generate a summary/decisions/action items for a finished meeting +/// (T5.4-5.6, FR-LLM-2/3/4). Streams tokens live via `llm://token`, persists +/// `summary.json`, and emits `llm://done` with the full result. Drafted +/// action items are NOT written to the `action_items` table here — the user +/// reviews/edits them first; `confirm_action_items` is what actually creates +/// rows (FR-LLM-3). #[tauri::command] pub async fn generate_summary( - _meeting_id: MeetingId, - _template_id: Option, + app: AppHandle, + state: State<'_, AppState>, + meeting_id: MeetingId, + template_id: Option, ) -> WaResult<()> { - // Streams via "llm://token" / "llm://done". - todo!("Phase 5 — generate_summary") + let guard = state.session.lock().await; + if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) { + return Err(WaError::new( + "llm", + "cannot generate a summary while this meeting is still recording — wait until it's stopped", + )); + } + drop(guard); + + let settings = load_settings(); + let provider = llm_provider_from_settings(&settings).ok_or_else(|| { + WaError::new( + "llm", + "no LLM provider is configured — enable one in Settings first", + ) + })?; + + let meeting = state + .store + .get_meeting(&meeting_id) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + let prompt = build_prompt(&meeting, template_id.as_deref()); + + // `LlmProvider::summarize` streams tokens over a std (blocking) mpsc + // channel; drain it on its own thread so tokens reach the frontend as + // they arrive rather than batched after the whole reply completes. + let (tx, rx) = std::sync::mpsc::channel::(); + let app_for_tokens = app.clone(); + let meeting_id_for_tokens = meeting_id.clone(); + let forward = std::thread::spawn(move || { + while let Ok(text) = rx.recv() { + let _ = app_for_tokens.emit( + "llm://token", + serde_json::json!({ "meetingId": meeting_id_for_tokens, "text": text }), + ); + } + }); + let result = provider.summarize(prompt, tx).await; + let _ = forward.join(); // rx errs (and the thread exits) once summarize() drops its Sender + + let summary = result.map_err(|e| WaError::new("llm", e.to_string()))?; + let summary_file = SummaryFile { + schema: 1, + generated_at: now_unix(), + provider: settings.llm_provider, + model: settings.llm_model, + summary_md: summary.summary_md, + decisions: summary.decisions, + action_items: summary.action_items, + }; + let json = serde_json::to_string_pretty(&summary_file) + .map_err(|e| WaError::new("llm", e.to_string()))?; + let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), json); + + let _ = app.emit( + "llm://done", + serde_json::json!({ "meetingId": meeting_id, "summary": summary_file }), + ); + Ok(()) +} + +/// Persist reviewed/edited action items as confirmed tasks (T5.6, FR-LLM-3). +#[tauri::command] +pub async fn confirm_action_items( + state: State<'_, AppState>, + meeting_id: MeetingId, + items: Vec, +) -> WaResult<()> { + state + .store + .save_action_items(&meeting_id, &items) + .await + .map_err(|e| WaError::new("storage", e.to_string())) } // ---- Calendar / .pst (Phase 6) ---- @@ -1243,4 +1467,54 @@ mod tests { // graceful-degradation path a recording never blocks on (T4.3). assert!(diarizer_from_installed_models().is_none()); } + + fn meeting_fixture() -> Meeting { + Meeting { + id: "m1".to_string(), + title: "Sprint planning".to_string(), + started_at: 0, + ended_at: None, + duration_secs: Some(1830), // 30.5 min + status: MeetingStatus::Ready, + recorded: false, + language: None, + backend_used: None, + model_used: None, + segments: Vec::new(), + speakers: vec![ + SpeakerInfo { + label: "S1".to_string(), + display_name: Some("Alice".to_string()), + participant_id: None, + }, + SpeakerInfo { + label: "S2".to_string(), + display_name: None, + participant_id: None, + }, + ], + notes_markdown: "**Alice:** Let's plan the sprint.".to_string(), + summary: None, + } + } + + #[test] + fn build_prompt_includes_title_duration_and_named_or_labeled_speakers() { + let prompt = build_prompt(&meeting_fixture(), None); + assert!(prompt.metadata.contains("Sprint planning")); + assert!(prompt.metadata.contains("30 min")); + assert!(prompt.metadata.contains("Alice")); + assert!(prompt.metadata.contains("S2")); // unnamed speaker falls back to its label + assert_eq!(prompt.transcript, "**Alice:** Let's plan the sprint."); + assert!(prompt.template.is_none()); + } + + #[test] + fn build_prompt_resolves_a_known_template_id_and_ignores_an_unknown_one() { + let known = build_prompt(&meeting_fixture(), Some("standup")); + assert!(known.template.unwrap().to_lowercase().contains("standup")); + + let unknown = build_prompt(&meeting_fixture(), Some("no-such-template")); + assert!(unknown.template.is_none()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9905d25..1e7e115 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -142,6 +142,9 @@ pub fn run() { commands::llm_status, commands::set_llm_provider, commands::generate_summary, + commands::confirm_action_items, + commands::llm_setup_suggestions, + commands::pull_ollama_model, commands::import_pst, commands::list_sync_targets, commands::add_sync_target, diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 9f4c2ea..1a01e23 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -3,6 +3,8 @@ //! local — `is_local` gates a "data leaves WA" warning for remote endpoints. use async_trait::async_trait; +use futures_util::StreamExt; +use serde::Deserialize; #[derive(Debug, thiserror::Error)] pub enum LlmError { @@ -42,35 +44,493 @@ pub trait LlmProvider: Send + Sync { fn is_local(&self) -> bool; } +/// Host WA asks the model to structure its Markdown reply into, so the +/// provider can split the streamed text back into summary/decisions/action +/// items without a second LLM call. T5.4 (real prompt assembly from a +/// meeting's transcript+metadata) and T5.6 (richer action-item parsing) live +/// in `commands::generate_summary`; this is the provider-level contract they +/// build on. +const RESPONSE_FORMAT_INSTRUCTIONS: &str = "Respond in Markdown with exactly three sections, in \ + this order: \"## Summary\", \"## Decisions\", \"## Action Items\". Decisions and Action Items \ + are bullet lists (each line starting with \"- \"); write \"- None\" if a section has nothing \ + to report. Do not add any other top-level sections."; + +fn build_messages(prompt: &Prompt) -> Vec { + let mut user = String::new(); + if !prompt.metadata.is_empty() { + user.push_str(&prompt.metadata); + user.push_str("\n\n"); + } + if let Some(template) = &prompt.template { + user.push_str(template); + user.push_str("\n\n"); + } + user.push_str("Transcript:\n"); + user.push_str(&prompt.transcript); + vec![ + serde_json::json!({ "role": "system", "content": RESPONSE_FORMAT_INSTRUCTIONS }), + serde_json::json!({ "role": "user", "content": user }), + ] +} + +/// Splits a "## Summary" / "## Decisions" / "## Action Items" Markdown reply +/// (see `RESPONSE_FORMAT_INSTRUCTIONS`) into a `Summary`. Unconfirmed action +/// items (FR-LLM-3) — the user confirms them via `confirm_action_items`. +fn parse_summary(text: &str) -> Summary { + let mut summary = String::new(); + let mut decisions = Vec::new(); + let mut actions = Vec::new(); + let mut section = 0i8; // 0 = summary, 1 = decisions, 2 = actions, -1 = other/unknown heading + + for line in text.lines() { + let lower = line.trim().to_ascii_lowercase(); + if lower.starts_with("## summary") { + section = 0; + continue; + } + if lower.starts_with("## decision") { + section = 1; + continue; + } + if lower.starts_with("## action item") { + section = 2; + continue; + } + if line.trim_start().starts_with('#') { + section = -1; + continue; + } + match section { + 0 => { + summary.push_str(line); + summary.push('\n'); + } + 1 => { + if let Some(item) = bullet_text(line) { + decisions.push(item); + } + } + 2 => { + if let Some(item) = bullet_text(line) { + actions.push(item); + } + } + _ => {} + } + } + + Summary { + summary_md: summary.trim().to_string(), + decisions, + action_items: actions + .into_iter() + .map(|text| crate::models::ActionItem { + id: None, + text, + owner: None, + due_at: None, + confirmed: false, + }) + .collect(), + } +} + +/// `- text` / `* text` -> `Some("text")`; skips empty bullets and the +/// placeholder "- None" the prompt asks for when a section is empty. +fn bullet_text(line: &str) -> Option { + let trimmed = line.trim(); + let stripped = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* "))?; + let text = stripped.trim(); + if text.is_empty() || text.eq_ignore_ascii_case("none") { + None + } else { + Some(text.to_string()) + } +} + +fn is_loopback_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + // `Url::host_str()` keeps the brackets around an IPv6 literal (e.g. "[::1]"), + // which `IpAddr`'s parser rejects — strip them before parsing. + let host = host.trim_start_matches('[').trim_end_matches(']'); + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn is_local_endpoint(endpoint: &str) -> bool { + reqwest::Url::parse(endpoint) + .ok() + .and_then(|u| u.host_str().map(is_loopback_host)) + .unwrap_or(false) +} + +/// Streams an HTTP response body as UTF-8 lines to `on_line`, buffering +/// partial reads. Shared by Ollama's raw-NDJSON stream and the OpenAI- +/// compatible SSE stream below — both are "one JSON blob per line". +/// `on_line` returns `false` to stop reading early (e.g. once a stream's own +/// "done" marker is seen) rather than waiting for the connection to close. +async fn stream_lines( + resp: reqwest::Response, + mut on_line: impl FnMut(&str) -> bool, +) -> Result<(), LlmError> { + let mut buf: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + 'outer: while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| LlmError::Request(e.to_string()))?; + buf.extend_from_slice(&chunk); + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let line: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line[..line.len() - 1]); + let line = line.trim(); + if !line.is_empty() && !on_line(line) { + break 'outer; + } + } + } + Ok(()) +} + /// Ollama provider (default). Talks to /api/tags, /api/chat (streaming), /api/pull. pub struct OllamaProvider { pub endpoint: String, // e.g. http://localhost:11434 pub model: String, } -#[async_trait] -impl LlmProvider for OllamaProvider { - async fn status(&self) -> LlmStatus { - todo!("Phase 5 — GET /api/tags, reachability") - } - async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { - todo!("Phase 5 — POST /api/chat streaming + parse action items") - } - fn is_local(&self) -> bool { - // T5.3: parse host, assert loopback (127.0.0.1/::1/localhost). - todo!("Phase 5 — local-endpoint guard") +#[derive(Deserialize)] +struct OllamaTagsResponse { + #[serde(default)] + models: Vec, +} + +#[derive(Deserialize)] +struct OllamaTagModel { + name: String, +} + +#[derive(Deserialize)] +struct OllamaChatChunk { + #[serde(default)] + message: Option, + #[serde(default)] + done: bool, +} + +#[derive(Deserialize)] +struct OllamaChatMessage { + #[serde(default)] + content: String, +} + +impl OllamaProvider { + fn base(&self) -> &str { + self.endpoint.trim_end_matches('/') } } -// ---- Hosted providers (Phase 10a, ADR-0011) ---- -// Same `LlmProvider` trait; `is_local()` returns false so the UI warns. API keys are read from -// the OS credential store, never settings/DB. Hosts join the egress allowlist when configured. +#[async_trait] +impl LlmProvider for OllamaProvider { + async fn status(&self) -> LlmStatus { + let models = match reqwest::get(format!("{}/api/tags", self.base())).await { + Ok(resp) if resp.status().is_success() => resp + .json::() + .await + .map(|t| t.models.into_iter().map(|m| m.name).collect()) + .unwrap_or_default(), + _ => { + return LlmStatus { + provider: "ollama".to_string(), + reachable: false, + is_local: self.is_local(), + models: Vec::new(), + } + } + }; + LlmStatus { + provider: "ollama".to_string(), + reachable: true, + is_local: self.is_local(), + models, + } + } -/// OpenAI-compatible (`/v1/chat/completions`): OpenAI, OpenRouter, LM Studio, gateways. + async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result { + let body = serde_json::json!({ + "model": self.model, + "messages": build_messages(&prompt), + "stream": true, + }); + let resp = reqwest::Client::new() + .post(format!("{}/api/chat", self.base())) + .json(&body) + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + + let mut full_text = String::new(); + stream_lines(resp, |line| { + let Ok(chunk) = serde_json::from_str::(line) else { + return true; // ignore an unparseable line, keep reading + }; + if let Some(content) = chunk.message.map(|m| m.content) { + if !content.is_empty() { + full_text.push_str(&content); + let _ = out.send(content); + } + } + !chunk.done + }) + .await?; + + Ok(parse_summary(&full_text)) + } + + fn is_local(&self) -> bool { + is_local_endpoint(&self.endpoint) + } +} + +// ---- OpenAI-compatible endpoint (Phase 5's "custom" option; Phase 10a reuses ---- +// this for hosted providers by setting `credential_ref`, ADR-0011.) + +/// OpenAI-compatible (`/v1/chat/completions`): a local server the user runs +/// (Unsloth-served models, llama.cpp server, LM Studio, etc. — ADR-0007) when +/// `credential_ref` is `None`; a hosted gateway (OpenAI, OpenRouter) once +/// Phase 10a sets it. pub struct OpenAiCompatProvider { pub endpoint: String, pub model: String, - pub credential_ref: String, + pub credential_ref: Option, +} + +#[derive(Deserialize)] +struct OpenAiModelsResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Deserialize)] +struct OpenAiModel { + id: String, +} + +#[derive(Deserialize)] +struct OpenAiChatChunk { + #[serde(default)] + choices: Vec, +} + +#[derive(Deserialize)] +struct OpenAiChoice { + #[serde(default)] + delta: OpenAiDelta, +} + +#[derive(Deserialize, Default)] +struct OpenAiDelta { + #[serde(default)] + content: Option, +} + +impl OpenAiCompatProvider { + fn base(&self) -> &str { + self.endpoint.trim_end_matches('/') + } + + /// Reads the API key from the OS credential store (never settings/DB — + /// FR-SEC-1). `None` for local, unauthenticated custom endpoints. + fn api_key(&self) -> Option { + // T10a.2: `keyring` lookup by `credential_ref` lands with hosted + // providers; a local custom endpoint has no `credential_ref` at all. + None + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self.api_key() { + Some(key) => req.bearer_auth(key), + None => req, + } + } +} + +#[async_trait] +impl LlmProvider for OpenAiCompatProvider { + async fn status(&self) -> LlmStatus { + let req = self.auth(reqwest::Client::new().get(format!("{}/v1/models", self.base()))); + let models = match req.send().await { + Ok(resp) if resp.status().is_success() => resp + .json::() + .await + .map(|r| r.data.into_iter().map(|m| m.id).collect()) + .unwrap_or_default(), + _ => { + return LlmStatus { + provider: "custom".to_string(), + reachable: false, + is_local: self.is_local(), + models: Vec::new(), + } + } + }; + LlmStatus { + provider: "custom".to_string(), + reachable: true, + is_local: self.is_local(), + models, + } + } + + async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result { + let body = serde_json::json!({ + "model": self.model, + "messages": build_messages(&prompt), + "stream": true, + }); + let req = self.auth( + reqwest::Client::new() + .post(format!("{}/v1/chat/completions", self.base())) + .json(&body), + ); + let resp = req + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + + let mut full_text = String::new(); + stream_lines(resp, |line| { + let Some(payload) = line.strip_prefix("data:") else { + return true; + }; + let payload = payload.trim(); + if payload == "[DONE]" { + return false; + } + let Ok(chunk) = serde_json::from_str::(payload) else { + return true; // ignore an unparseable line, keep reading + }; + for choice in chunk.choices { + if let Some(content) = choice.delta.content { + if !content.is_empty() { + full_text.push_str(&content); + let _ = out.send(content); + } + } + } + true + }) + .await?; + + Ok(parse_summary(&full_text)) + } + + fn is_local(&self) -> bool { + self.credential_ref.is_none() && is_local_endpoint(&self.endpoint) + } +} + +// ---- Guided setup (T5.7, FR-LLM-5) ---- + +/// Best-effort check for an Ollama install on Windows: the installer's +/// default per-user location and the less common system-wide one. Only used +/// to tell "not installed" from "installed but not running" for the guided- +/// setup prompt — `llm_status`'s HTTP reachability check is still the source +/// of truth for whether it's actually usable right now. +pub fn ollama_installed() -> bool { + let mut candidates = vec![std::path::PathBuf::from( + r"C:\Program Files\Ollama\ollama.exe", + )]; + if let Ok(local_appdata) = std::env::var("LOCALAPPDATA") { + candidates.push( + std::path::PathBuf::from(local_appdata) + .join("Programs") + .join("Ollama") + .join("ollama.exe"), + ); + } + candidates.iter().any(|p| p.exists()) +} + +pub struct ModelSuggestion { + pub id: String, // Ollama model tag, e.g. "llama3.2:3b" + pub label: String, + pub approx_size_gb: f32, +} + +/// Hardware-aware default model suggestion (T5.7, FR-LLM-5) — a small fixed +/// tier table, not a fetched catalog: Ollama's own model set changes +/// independently of WA, and `pull_model` below handles the actual download. +/// `vram_mb` should come from the best available backend +/// (`HardwareDetector::best`); `None` (CPU-only, or a GPU DXGI couldn't read +/// VRAM for) gets the smallest, safest suggestion. +pub fn suggest_ollama_model(vram_mb: Option) -> ModelSuggestion { + match vram_mb { + Some(mb) if mb >= 8192 => ModelSuggestion { + id: "llama3.1:8b".to_string(), + label: "Llama 3.1 8B — best quality this hardware comfortably supports".to_string(), + approx_size_gb: 4.7, + }, + Some(mb) if mb >= 4096 => ModelSuggestion { + id: "llama3.2:3b".to_string(), + label: "Llama 3.2 3B — balanced for mid-range GPUs".to_string(), + approx_size_gb: 2.0, + }, + _ => ModelSuggestion { + id: "llama3.2:1b".to_string(), + label: "Llama 3.2 1B — fastest, runs well on CPU-only".to_string(), + approx_size_gb: 1.3, + }, + } +} + +#[derive(Deserialize)] +struct OllamaPullChunk { + #[serde(default)] + status: String, + #[serde(default)] + completed: Option, + #[serde(default)] + total: Option, +} + +/// Guided model download via Ollama's own `/api/pull` (ADR-0007). Reports +/// progress through the same `(received, total)` shape the whisper/ +/// diarization model catalogs use, so the frontend doesn't need a second +/// progress UI for this one. +pub async fn pull_model( + endpoint: &str, + model: &str, + mut on_progress: impl FnMut(u64, Option), +) -> Result<(), LlmError> { + let resp = reqwest::Client::new() + .post(format!("{}/api/pull", endpoint.trim_end_matches('/'))) + .json(&serde_json::json!({ "name": model, "stream": true })) + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + + stream_lines(resp, |line| { + let Ok(chunk) = serde_json::from_str::(line) else { + return true; // ignore an unparseable line, keep reading + }; + if let Some(completed) = chunk.completed { + on_progress(completed, chunk.total); + } + chunk.status != "success" + }) + .await } /// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-compatible. @@ -79,19 +539,6 @@ pub struct AnthropicProvider { pub credential_ref: String, } -#[async_trait] -impl LlmProvider for OpenAiCompatProvider { - async fn status(&self) -> LlmStatus { - todo!("Phase 10a — OpenAI-compatible status") - } - async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { - todo!("Phase 10a — POST /v1/chat/completions streaming") - } - fn is_local(&self) -> bool { - false - } -} - #[async_trait] impl LlmProvider for AnthropicProvider { async fn status(&self) -> LlmStatus { @@ -104,3 +551,78 @@ impl LlmProvider for AnthropicProvider { false } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_summary_splits_the_three_requested_sections() { + let text = "## Summary\nWe discussed the roadmap.\nIt went well.\n\n\ + ## Decisions\n- Ship phase 5 first\n- None\n\n\ + ## Action Items\n- Alice to write the ADR\n* Bob to review PRs\n"; + let summary = parse_summary(text); + assert_eq!( + summary.summary_md, + "We discussed the roadmap.\nIt went well." + ); + assert_eq!(summary.decisions, vec!["Ship phase 5 first"]); + assert_eq!(summary.action_items.len(), 2); + assert_eq!(summary.action_items[0].text, "Alice to write the ADR"); + assert_eq!(summary.action_items[1].text, "Bob to review PRs"); + assert!(summary.action_items.iter().all(|a| !a.confirmed)); + } + + #[test] + fn parse_summary_handles_all_sections_empty() { + let text = "## Summary\n\n## Decisions\n- None\n\n## Action Items\n- None\n"; + let summary = parse_summary(text); + assert_eq!(summary.summary_md, ""); + assert!(summary.decisions.is_empty()); + assert!(summary.action_items.is_empty()); + } + + #[test] + fn is_local_endpoint_accepts_loopback_forms_and_rejects_remote_hosts() { + assert!(is_local_endpoint("http://localhost:11434")); + assert!(is_local_endpoint("http://127.0.0.1:11434")); + assert!(is_local_endpoint("http://[::1]:11434")); + assert!(!is_local_endpoint("https://api.example.com")); + assert!(!is_local_endpoint("not a url")); + } + + #[test] + fn openai_compat_provider_is_not_local_once_a_credential_is_set() { + let local = OpenAiCompatProvider { + endpoint: "http://localhost:8080".to_string(), + model: "local-model".to_string(), + credential_ref: None, + }; + assert!(local.is_local()); + + let hosted = OpenAiCompatProvider { + endpoint: "http://localhost:8080".to_string(), + model: "local-model".to_string(), + credential_ref: Some("openai-key".to_string()), + }; + assert!(!hosted.is_local()); + } + + #[test] + fn suggest_ollama_model_scales_up_with_more_vram() { + assert_eq!(suggest_ollama_model(None).id, "llama3.2:1b"); + assert_eq!(suggest_ollama_model(Some(2048)).id, "llama3.2:1b"); + assert_eq!(suggest_ollama_model(Some(4096)).id, "llama3.2:3b"); + assert_eq!(suggest_ollama_model(Some(6000)).id, "llama3.2:3b"); + assert_eq!(suggest_ollama_model(Some(8192)).id, "llama3.1:8b"); + assert_eq!(suggest_ollama_model(Some(24576)).id, "llama3.1:8b"); + } + + #[test] + fn ollama_installed_is_false_when_neither_candidate_path_exists() { + // This test environment never has Ollama installed at either + // well-known Windows path — confirms the check doesn't panic/assume + // presence (T5.7's guided-install prompt depends on this being honest). + assert!(!ollama_installed()); + } +} diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 5d01c90..9a885da 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -4,7 +4,9 @@ //! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/ //! summary) are regenerable; retention never touches an in-progress meeting. -use crate::models::{MeetingId, MeetingListItem, MeetingStatus, SpeakerInfo, TranscriptSegment}; +use crate::models::{ + ActionItem, MeetingId, MeetingListItem, MeetingStatus, SpeakerInfo, TranscriptSegment, +}; use crate::paths; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -65,6 +67,23 @@ pub struct Meeting { pub segments: Vec, pub speakers: Vec, pub notes_markdown: String, + /// `None` until `generate_summary` has run at least once (Phase 5, FR-LLM-4). + pub summary: Option, +} + +/// On-disk shape of `summary.json` (`docs/03-data-model.md`). Drafted action +/// items here are NOT yet rows in the `action_items` table — the user +/// reviews/edits them first; `confirm_action_items` is what persists them +/// (FR-LLM-3). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SummaryFile { + pub schema: u32, + pub generated_at: i64, + pub provider: String, + pub model: String, + pub summary_md: String, + pub decisions: Vec, + pub action_items: Vec, } pub struct Retention { @@ -101,6 +120,15 @@ pub trait Store: Send + Sync { from: &[String], into: &str, ) -> Result<(), StoreError>; + /// Persist reviewed/edited action items as confirmed tasks (T5.6, + /// FR-LLM-3): items with an `id` update that row, items without one + /// insert a new row. Drafted (unconfirmed) items from `generate_summary` + /// only ever live in `summary.json` until this is called. + async fn save_action_items( + &self, + id: &MeetingId, + items: &[ActionItem], + ) -> Result<(), StoreError>; /// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1). async fn search(&self, query: &str) -> Result, StoreError>; /// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1). @@ -347,6 +375,9 @@ impl Store for SqliteStore { } } let notes_markdown = std::fs::read_to_string(folder.join("notes.md")).unwrap_or_default(); + let summary = std::fs::read_to_string(folder.join("summary.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); Ok(Meeting { id: row.get("id"), @@ -362,6 +393,7 @@ impl Store for SqliteStore { segments, speakers, notes_markdown, + summary, }) } @@ -433,6 +465,48 @@ impl Store for SqliteStore { Ok(()) } + async fn save_action_items( + &self, + id: &MeetingId, + items: &[ActionItem], + ) -> Result<(), StoreError> { + let now = now_unix(); + for item in items { + match &item.id { + Some(existing_id) => { + sqlx::query( + "UPDATE action_items SET text = ?, owner = ?, due_at = ?, confirmed = ? + WHERE id = ? AND meeting_id = ?", + ) + .bind(&item.text) + .bind(&item.owner) + .bind(item.due_at) + .bind(item.confirmed as i64) + .bind(existing_id) + .bind(id) + .execute(&self.pool) + .await?; + } + None => { + sqlx::query( + "INSERT INTO action_items (id, meeting_id, text, owner, due_at, confirmed, reminder_set, created_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(id) + .bind(&item.text) + .bind(&item.owner) + .bind(item.due_at) + .bind(item.confirmed as i64) + .bind(now) + .execute(&self.pool) + .await?; + } + } + } + Ok(()) + } + async fn search(&self, _query: &str) -> Result, StoreError> { todo!("Phase 8 — FTS5 search") }