575 lines
21 KiB
Rust
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(_))));
|
|
}
|
|
}
|