This commit is contained in:
iamdoubz
2026-07-01 10:54:03 -05:00
parent 1fae617f2d
commit 27373effb7
6 changed files with 922 additions and 43 deletions
+284 -10
View File
@@ -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<Box<dyn crate::llm::LlmProvider>> {
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<serde_json::Value> {
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::<String>::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<String>,
pub model: Option<String>,
}
/// 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<serde_json::Value> {
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<String> = 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<String>,
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
template_id: Option<String>,
) -> 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::<String>();
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<ActionItem>,
) -> 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());
}
}
+3
View File
@@ -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,
+551 -29
View File
@@ -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<serde_json::Value> {
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<String> {
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::<std::net::IpAddr>()
.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<u8> = 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<u8> = 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<Summary, LlmError> {
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<OllamaTagModel>,
}
#[derive(Deserialize)]
struct OllamaTagModel {
name: String,
}
#[derive(Deserialize)]
struct OllamaChatChunk {
#[serde(default)]
message: Option<OllamaChatMessage>,
#[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::<OllamaTagsResponse>()
.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<Summary, LlmError> {
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::<OllamaChatChunk>(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<String>,
}
#[derive(Deserialize)]
struct OpenAiModelsResponse {
#[serde(default)]
data: Vec<OpenAiModel>,
}
#[derive(Deserialize)]
struct OpenAiModel {
id: String,
}
#[derive(Deserialize)]
struct OpenAiChatChunk {
#[serde(default)]
choices: Vec<OpenAiChoice>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
#[serde(default)]
delta: OpenAiDelta,
}
#[derive(Deserialize, Default)]
struct OpenAiDelta {
#[serde(default)]
content: Option<String>,
}
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<String> {
// 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::<OpenAiModelsResponse>()
.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<Summary, LlmError> {
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::<OpenAiChatChunk>(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<u32>) -> 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<u64>,
#[serde(default)]
total: Option<u64>,
}
/// 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<u64>),
) -> 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::<OllamaPullChunk>(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<Summary, LlmError> {
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());
}
}
+75 -1
View File
@@ -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<TranscriptSegment>,
pub speakers: Vec<SpeakerInfo>,
pub notes_markdown: String,
/// `None` until `generate_summary` has run at least once (Phase 5, FR-LLM-4).
pub summary: Option<SummaryFile>,
}
/// 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<String>,
pub action_items: Vec<ActionItem>,
}
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<Vec<MeetingListItem>, 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::<SummaryFile>(&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<Vec<MeetingListItem>, StoreError> {
todo!("Phase 8 — FTS5 search")
}