Files
WhispAssist/src-tauri/src/briefs/mod.rs
T

575 lines
21 KiB
Rust

//! Feature-brief distiller (Phase 10 M1, ADR-0011, FR-MCP-4/T10.6). Turns a
//! finished meeting's transcript into an agent-ready spec via the configured
//! `LlmProvider` — no new egress, no new dependency: it's the same provider
//! `generate_summary` already talks to.
//!
//! The distillation itself (`distill`) is a plain function over an
//! `LlmProvider` + transcript data, independent of `Store` — that's what lets
//! the golden-transcript test exercise it with a `MockLlmProvider` and no
//! database at all. `LlmFeatureBriefBuilder` is the thin `Store`-aware
//! adapter the `FeatureBriefBuilder` trait (`docs/04-api-contracts.md`)
//! describes, used by `commands::create_feature_brief`.
use crate::llm::{bullet_text, LlmError, LlmProvider};
use crate::models::{ContextExcerpt, FeatureBrief, MeetingId, SpeakerInfo, TranscriptSegment};
use crate::storage::{Store, StoreError};
use async_trait::async_trait;
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum BriefError {
#[error("storage error: {0}")]
Store(#[from] StoreError),
#[error("llm error: {0}")]
Llm(#[from] LlmError),
}
/// Builds the agent-ready spec from a transcript via the configured
/// `LlmProvider` (`docs/04-api-contracts.md`).
#[async_trait]
pub trait FeatureBriefBuilder: Send + Sync {
async fn build(
&self,
meeting_id: &MeetingId,
target_repo: Option<&str>,
) -> Result<FeatureBrief, BriefError>;
}
/// System prompt contract (mirrors `llm::RESPONSE_FORMAT_INSTRUCTIONS`):
/// exactly four sections, in order, nothing invented beyond the transcript.
const BRIEF_INSTRUCTIONS: &str = "Distill this transcript into an implementation brief for a \
coding agent. Respond in Markdown with exactly, in order: \"## Title\" (one line), \
\"## Problem\", \"## Desired Outcome\", \"## Acceptance Criteria\" (a \"- \" bullet list, one \
testable criterion per line). Be concrete and terse; invent nothing not in the transcript; no \
other sections.";
/// Parsed reply, before assembly into the IPC/file shapes.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BriefFields {
pub title: String,
pub problem: String,
pub desired_outcome: String,
pub acceptance_criteria: Vec<String>,
}
/// Assembles the (system, user) prompt pair — metadata + optional
/// `target_repo` hint + the transcript, mirroring `commands::build_prompt`'s
/// shape for `summarize`.
fn build_brief_messages(
meeting_title: &str,
participants: &[String],
target_repo: Option<&str>,
transcript: &str,
) -> (String, String) {
let mut user = format!("Meeting: {meeting_title}\n");
if !participants.is_empty() {
user.push_str(&format!("Participants: {}\n", participants.join(", ")));
}
if let Some(repo) = target_repo {
user.push_str(&format!("Target repo: {repo}\n"));
}
user.push_str("\nTranscript:\n");
user.push_str(transcript);
(BRIEF_INSTRUCTIONS.to_string(), user)
}
/// Splits a "## Title / ## Problem / ## Desired Outcome / ## Acceptance
/// Criteria" Markdown reply (see `BRIEF_INSTRUCTIONS`) into `BriefFields` —
/// mirrors `llm::parse_summary`. A missing/malformed section never panics:
/// `title`/`problem`/`desired_outcome` fall back to `""` (title falls back
/// further, to `meeting_title`, since a brief with no title at all is
/// unusable), and `acceptance_criteria` falls back to `[]`.
pub fn parse_brief(md: &str, meeting_title: &str) -> BriefFields {
let mut title = String::new();
let mut problem = String::new();
let mut desired_outcome = String::new();
let mut acceptance_criteria = Vec::new();
let mut section = -1i8; // 0 title, 1 problem, 2 desired outcome, 3 acceptance criteria, -1 other/unknown
for line in md.lines() {
let lower = line.trim().to_ascii_lowercase();
if lower.starts_with("## title") {
section = 0;
continue;
}
if lower.starts_with("## problem") {
section = 1;
continue;
}
if lower.starts_with("## desired outcome") {
section = 2;
continue;
}
if lower.starts_with("## acceptance criteria") {
section = 3;
continue;
}
if line.trim_start().starts_with('#') {
section = -1;
continue;
}
match section {
0 => {
let text = line.trim();
if !text.is_empty() {
if !title.is_empty() {
title.push(' ');
}
title.push_str(text);
}
}
1 => {
problem.push_str(line);
problem.push('\n');
}
2 => {
desired_outcome.push_str(line);
desired_outcome.push('\n');
}
3 => {
if let Some(item) = bullet_text(line) {
acceptance_criteria.push(item);
}
}
_ => {}
}
}
let title = title.trim().to_string();
BriefFields {
title: if title.is_empty() {
meeting_title.to_string()
} else {
title
},
problem: problem.trim().to_string(),
desired_outcome: desired_outcome.trim().to_string(),
acceptance_criteria,
}
}
/// Lowercased alphanumeric words of length >= 3 — short enough to skip
/// common stopwords ("the", "to", "we") without a stopword list, long enough
/// to still catch meaningful terms.
fn keywords(text: &str) -> HashSet<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 3)
.map(|w| w.to_ascii_lowercase())
.collect()
}
fn display_name(label: &str, speakers: &[SpeakerInfo]) -> String {
speakers
.iter()
.find(|s| s.label == label)
.and_then(|s| s.display_name.clone())
.unwrap_or_else(|| label.to_string())
}
const MIN_EXCERPT_LEN: usize = 40;
const MAX_EXCERPTS: usize = 5;
const FALLBACK_EXCERPTS: usize = 3;
/// Selects grounding excerpts for a brief (the M1 grounding invariant: every
/// returned `text` is copied verbatim from a transcript segment — never
/// model paraphrase). Scores each substantive segment (>= ~40 chars) by
/// keyword overlap with `problem` + `acceptance_criteria`, taking the top
/// <= 5; falls back to the first 3 substantive segments if nothing scores
/// (e.g. a terse reply with too few keywords, or a transcript that just
/// doesn't share vocabulary with the drafted brief).
// ponytail: keyword-overlap select; upgrade to embedding similarity if excerpts feel off
fn context_excerpts(
fields: &BriefFields,
segments: &[TranscriptSegment],
speakers: &[SpeakerInfo],
) -> Vec<ContextExcerpt> {
let mut query = keywords(&fields.problem);
query.extend(keywords(&fields.acceptance_criteria.join(" ")));
let substantive: Vec<&TranscriptSegment> = segments
.iter()
.filter(|s| s.text.trim().len() >= MIN_EXCERPT_LEN)
.collect();
if !query.is_empty() {
let mut scored: Vec<(usize, &TranscriptSegment)> = substantive
.iter()
.map(|&s| (keywords(&s.text).intersection(&query).count(), s))
.filter(|(overlap, _)| *overlap > 0)
.collect();
if !scored.is_empty() {
// Stable sort keeps original (chronological) order among ties.
scored.sort_by(|a, b| b.0.cmp(&a.0));
return scored
.into_iter()
.take(MAX_EXCERPTS)
.map(|(_, s)| ContextExcerpt {
speaker: display_name(&s.speaker, speakers),
text: s.text.clone(),
})
.collect();
}
}
substantive
.into_iter()
.take(FALLBACK_EXCERPTS)
.map(|s| ContextExcerpt {
speaker: display_name(&s.speaker, speakers),
text: s.text.clone(),
})
.collect()
}
/// Transcript-derived inputs `distill` needs — bundled into one struct so the
/// function stays under clippy's argument-count lint rather than taking each
/// field positionally.
struct MeetingContext<'a> {
title: &'a str,
participants: &'a [String],
transcript_md: &'a str,
segments: &'a [TranscriptSegment],
speakers: &'a [SpeakerInfo],
}
/// Core distillation: prompt -> LLM round trip -> parse -> ground. Takes
/// transcript data directly rather than a `MeetingId`, so it needs no
/// `Store` — `LlmFeatureBriefBuilder::build` below is the `Store`-aware
/// wrapper that looks the meeting up first.
async fn distill(
llm: &dyn LlmProvider,
meeting_id: &MeetingId,
target_repo: Option<&str>,
ctx: &MeetingContext<'_>,
) -> Result<FeatureBrief, BriefError> {
let (system, user) =
build_brief_messages(ctx.title, ctx.participants, target_repo, ctx.transcript_md);
let reply = llm.complete(&system, &user).await?;
let fields = parse_brief(&reply, ctx.title);
let context_excerpts = context_excerpts(&fields, ctx.segments, ctx.speakers);
Ok(FeatureBrief {
id: uuid::Uuid::new_v4().to_string(),
meeting_id: meeting_id.clone(),
title: fields.title,
problem: fields.problem,
desired_outcome: fields.desired_outcome,
acceptance_criteria: fields.acceptance_criteria,
target_repo: target_repo.map(str::to_string),
context_excerpts,
})
}
/// `FeatureBriefBuilder` impl used by `commands::create_feature_brief`:
/// loads the meeting via `store`, then distills it with `llm`.
pub struct LlmFeatureBriefBuilder {
pub store: Arc<dyn Store>,
pub llm: Box<dyn LlmProvider>,
}
#[async_trait]
impl FeatureBriefBuilder for LlmFeatureBriefBuilder {
async fn build(
&self,
meeting_id: &MeetingId,
target_repo: Option<&str>,
) -> Result<FeatureBrief, BriefError> {
let meeting = self.store.get_meeting(meeting_id).await?;
let participants: Vec<String> = meeting
.speakers
.iter()
.map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone()))
.collect();
let ctx = MeetingContext {
title: &meeting.title,
participants: &participants,
transcript_md: &meeting.notes_markdown,
segments: &meeting.segments,
speakers: &meeting.speakers,
};
distill(self.llm.as_ref(), meeting_id, target_repo, &ctx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{LlmStatus, Prompt, Summary, TokenSink};
/// No-network stand-in for a real provider (T10.6 test — the golden-
/// transcript builder test must never touch a socket). Only `complete`
/// is exercised by `distill`; the rest are unused stubs.
struct MockLlmProvider {
reply: String,
}
#[async_trait]
impl LlmProvider for MockLlmProvider {
async fn status(&self) -> LlmStatus {
LlmStatus {
provider: "mock".to_string(),
reachable: true,
is_local: true,
models: Vec::new(),
}
}
async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result<Summary, LlmError> {
unimplemented!("not exercised by the brief-builder test")
}
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
Ok(Vec::new())
}
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
Ok(self.reply.clone())
}
fn is_local(&self) -> bool {
true
}
}
const GOLDEN_REPLY: &str = "## Title\n\
Bulk CSV export for the reporting view\n\n\
## Problem\n\
Customers can't get their filtered report data out for offline analysis.\n\n\
## Desired Outcome\n\
One-click CSV export of the current filtered report.\n\n\
## Acceptance Criteria\n\
- Export button on the report toolbar\n\
- Respects active filters and column order\n\
- Streams large exports without blocking the UI\n";
fn seg(id: u64, speaker: &str, text: &str) -> TranscriptSegment {
TranscriptSegment {
id,
start_ms: id * 1000,
end_ms: id * 1000 + 900,
speaker: speaker.to_string(),
text: text.to_string(),
confidence: Some(0.9),
interim: false,
}
}
fn golden_segments() -> Vec<TranscriptSegment> {
vec![
seg(0, "S1", "Let's start with the reporting view."),
seg(
1,
"S2",
"We really need to pull this filtered report into our own spreadsheets for offline analysis.",
),
seg(2, "S1", "Makes sense — what would the export button need to respect?"),
seg(
3,
"S2",
"It has to respect the active filters and the column order we've already set up.",
),
seg(4, "S1", "And it can't block the UI while a large export streams out."),
seg(5, "S2", "Right, exactly."),
]
}
fn golden_speakers() -> Vec<SpeakerInfo> {
vec![
SpeakerInfo {
label: "S1".to_string(),
display_name: Some("Alex".to_string()),
participant_id: None,
},
SpeakerInfo {
label: "S2".to_string(),
display_name: Some("Customer".to_string()),
participant_id: None,
},
]
}
// ---- parse_brief ----
#[test]
fn parse_brief_splits_the_four_requested_sections() {
let fields = parse_brief(GOLDEN_REPLY, "fallback title");
assert_eq!(fields.title, "Bulk CSV export for the reporting view");
assert_eq!(
fields.problem,
"Customers can't get their filtered report data out for offline analysis."
);
assert_eq!(
fields.desired_outcome,
"One-click CSV export of the current filtered report."
);
assert_eq!(
fields.acceptance_criteria,
vec![
"Export button on the report toolbar",
"Respects active filters and column order",
"Streams large exports without blocking the UI",
]
);
}
#[test]
fn parse_brief_falls_back_to_the_meeting_title_when_no_title_section() {
let text = "## Problem\nSomething broke.\n";
let fields = parse_brief(text, "Sprint planning");
assert_eq!(fields.title, "Sprint planning");
assert_eq!(fields.problem, "Something broke.");
assert_eq!(fields.desired_outcome, "");
assert!(fields.acceptance_criteria.is_empty());
}
#[test]
fn parse_brief_of_empty_or_malformed_input_is_empty_and_does_not_panic() {
let fields = parse_brief("", "Meeting title");
assert_eq!(fields.title, "Meeting title");
assert_eq!(fields.problem, "");
assert_eq!(fields.desired_outcome, "");
assert!(fields.acceptance_criteria.is_empty());
// No recognized headings at all — everything before the first `#`
// (there is none) is just unattributed prose, so nothing is captured.
let fields = parse_brief("just some prose with no headings", "Meeting title");
assert_eq!(fields.title, "Meeting title");
assert!(fields.problem.is_empty());
assert!(fields.acceptance_criteria.is_empty());
}
#[test]
fn parse_brief_ignores_unrecognized_extra_sections() {
let text = "## Title\nFix the thing\n\n## Notes\nirrelevant chatter\n\n\
## Acceptance Criteria\n- It works\n";
let fields = parse_brief(text, "fallback");
assert_eq!(fields.title, "Fix the thing");
assert_eq!(fields.acceptance_criteria, vec!["It works"]);
}
// ---- context_excerpts / grounding invariant ----
#[test]
fn context_excerpts_are_verbatim_substrings_of_a_transcript_segment() {
let fields = parse_brief(GOLDEN_REPLY, "fallback");
let segments = golden_segments();
let speakers = golden_speakers();
let excerpts = context_excerpts(&fields, &segments, &speakers);
assert!(!excerpts.is_empty());
for excerpt in &excerpts {
assert!(
segments.iter().any(|s| s.text.contains(&excerpt.text)),
"excerpt {:?} is not a verbatim substring of any transcript segment",
excerpt.text
);
}
}
#[test]
fn context_excerpts_resolve_speaker_display_names() {
let fields = parse_brief(GOLDEN_REPLY, "fallback");
let segments = golden_segments();
let speakers = golden_speakers();
let excerpts = context_excerpts(&fields, &segments, &speakers);
assert!(excerpts.iter().any(|e| e.speaker == "Customer"));
}
#[test]
fn context_excerpts_falls_back_to_first_substantive_segments_with_no_keyword_overlap() {
let fields = BriefFields {
title: "t".to_string(),
problem: "zzzzz qqqqq".to_string(), // shares no vocabulary with the transcript
desired_outcome: String::new(),
acceptance_criteria: vec!["wwwww".to_string()],
};
let segments = golden_segments();
let excerpts = context_excerpts(&fields, &segments, &golden_speakers());
assert_eq!(excerpts.len(), FALLBACK_EXCERPTS);
for excerpt in &excerpts {
assert!(segments.iter().any(|s| s.text.contains(&excerpt.text)));
}
}
// ---- distill (the FeatureBriefBuilder golden-transcript test) ----
#[tokio::test]
async fn distill_over_a_golden_transcript_yields_grounded_non_empty_criteria() {
let llm = MockLlmProvider {
reply: GOLDEN_REPLY.to_string(),
};
let segments = golden_segments();
let speakers = golden_speakers();
let participants: Vec<String> = speakers
.iter()
.map(|s| s.display_name.clone().unwrap())
.collect();
let transcript_md = segments
.iter()
.map(|s| format!("**{}:** {}", s.speaker, s.text))
.collect::<Vec<_>>()
.join("\n");
let ctx = MeetingContext {
title: "Reporting sync",
participants: &participants,
transcript_md: &transcript_md,
segments: &segments,
speakers: &speakers,
};
let brief = distill(&llm, &"m1".to_string(), Some("acme/reporting-web"), &ctx)
.await
.expect("distill should succeed against the mock provider");
assert_eq!(brief.meeting_id, "m1");
assert_eq!(brief.title, "Bulk CSV export for the reporting view");
assert!(!brief.acceptance_criteria.is_empty());
assert_eq!(brief.target_repo.as_deref(), Some("acme/reporting-web"));
assert!(!brief.context_excerpts.is_empty());
for excerpt in &brief.context_excerpts {
assert!(
segments.iter().any(|s| s.text.contains(&excerpt.text)),
"grounding invariant violated: {:?}",
excerpt.text
);
}
}
#[tokio::test]
async fn distill_propagates_an_llm_error_without_panicking() {
struct FailingProvider;
#[async_trait]
impl LlmProvider for FailingProvider {
async fn status(&self) -> LlmStatus {
LlmStatus {
provider: "mock".to_string(),
reachable: false,
is_local: true,
models: Vec::new(),
}
}
async fn summarize(
&self,
_prompt: Prompt,
_out: TokenSink,
) -> Result<Summary, LlmError> {
unimplemented!()
}
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
unimplemented!()
}
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
Err(LlmError::Unreachable("connection refused".to_string()))
}
fn is_local(&self) -> bool {
true
}
}
let ctx = MeetingContext {
title: "Meeting",
participants: &[],
transcript_md: "transcript",
segments: &[],
speakers: &[],
};
let result = distill(&FailingProvider, &"m1".to_string(), None, &ctx).await;
assert!(matches!(result, Err(BriefError::Llm(_))));
}
}