Phase 5
This commit is contained in:
+284
-10
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user