Files
WhispAssist/src-tauri/src/notes/mod.rs
T
iamdoubz f63076ad06 feat(templates): NoteTemplate catalog + apply in to_markdown (T8.1, FR-NOTE-5)
4 built-in templates (Standup, 1:1, Sales Call, Retro), keyed by the
same meeting-type ids commands::summary_prompt_bias already uses for
LLM-prompt biasing — one identifier space shared by both, but two
independent lookups (structuring notes.md vs. biasing a summary
prompt). Section headers are prepended as an empty scaffold above the
auto-rendered speaker dialogue.
2026-07-02 11:25:34 -05:00

426 lines
15 KiB
Rust

//! Notes assembly & export (Phase 2; PDF/Word in Phase 8). FR-NOTE-*.
//!
//! Renders speaker-tagged Markdown from transcript + speaker names (+ optional
//! summary). Names are resolved here from the mapping; segments keep internal IDs.
use crate::models::{SpeakerInfo, TranscriptSegment};
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum NotesError {
#[error("render failed: {0}")]
Render(String),
#[error("export failed: {0}")]
Export(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy)]
pub enum ExportFormat {
Md,
Pdf,
Docx,
Bundle, // audio + transcript + notes
}
/// A note template (Phase 8, T8.1, FR-NOTE-5) — section headers prepended
/// to `to_markdown`'s output, giving the user a scaffold to fill in by hand
/// above the auto-rendered speaker dialogue. Distinct from
/// `commands::summary_prompt_bias`, which biases the *LLM summary prompt*
/// for the same meeting-type identifiers rather than structuring notes.md.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteTemplate {
pub id: String,
pub name: String,
pub sections: Vec<String>,
}
/// Built-in templates, keyed by the same ids `commands::summary_prompt_bias`
/// already uses for its LLM-prompt biasing ("standup"/"retro"/"one-on-one"),
/// plus "sales-call" per FR-NOTE-5's example — one meeting-type identifier
/// space shared by both features.
pub fn built_in_note_templates() -> Vec<NoteTemplate> {
vec![
NoteTemplate {
id: "standup".to_string(),
name: "Standup".to_string(),
sections: vec![
"Yesterday".to_string(),
"Today".to_string(),
"Blockers".to_string(),
],
},
NoteTemplate {
id: "one-on-one".to_string(),
name: "1:1".to_string(),
sections: vec!["Discussion".to_string(), "Action Items".to_string()],
},
NoteTemplate {
id: "sales-call".to_string(),
name: "Sales Call".to_string(),
sections: vec![
"Agenda".to_string(),
"Discussion".to_string(),
"Next Steps".to_string(),
],
},
NoteTemplate {
id: "retro".to_string(),
name: "Retro".to_string(),
sections: vec![
"What Went Well".to_string(),
"What Didn't".to_string(),
"Action Items".to_string(),
],
},
]
}
pub fn note_template_by_id(id: &str) -> Option<NoteTemplate> {
built_in_note_templates().into_iter().find(|t| t.id == id)
}
pub trait NotesRenderer: Send + Sync {
/// Build Markdown with speaker-tagged dialogue (+ summary if present).
/// `template`'s section headers (if any) are prepended as an empty
/// scaffold for the user to fill in above the dialogue.
fn to_markdown(
&self,
segments: &[TranscriptSegment],
speakers: &[SpeakerInfo],
summary_md: Option<&str>,
template: Option<&NoteTemplate>,
) -> String;
fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat)
-> Result<PathBuf, NotesError>;
}
pub struct MarkdownNotes;
impl NotesRenderer for MarkdownNotes {
fn to_markdown(
&self,
segments: &[TranscriptSegment],
speakers: &[SpeakerInfo],
summary_md: Option<&str>,
template: Option<&NoteTemplate>,
) -> String {
let mut out = String::new();
if let Some(template) = template {
for section in &template.sections {
out.push_str(&format!("## {section}\n\n"));
}
}
if let Some(summary) = summary_md {
let summary = summary.trim();
if !summary.is_empty() {
out.push_str(summary);
out.push_str("\n\n---\n\n");
}
}
let name_for = |label: &str| -> String {
speakers
.iter()
.find(|s| s.label == label)
.and_then(|s| s.display_name.clone())
.unwrap_or_else(|| label.to_string())
};
// Group consecutive segments from the same speaker into one paragraph
// (matters once Phase 4 diarization produces more than one speaker).
let mut current_speaker: Option<&str> = None;
let mut buffer = String::new();
for seg in segments {
let text = seg.text.trim();
if text.is_empty() {
continue;
}
if current_speaker != Some(seg.speaker.as_str()) {
if let Some(speaker) = current_speaker {
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
}
current_speaker = Some(seg.speaker.as_str());
buffer.clear();
}
if !buffer.is_empty() {
buffer.push(' ');
}
buffer.push_str(text);
}
if let Some(speaker) = current_speaker {
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
}
out.trim_end().to_string()
}
fn export(
&self,
markdown: &str,
dest: &Path,
fmt: ExportFormat,
) -> Result<PathBuf, NotesError> {
match fmt {
ExportFormat::Md => {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, markdown)?;
Ok(dest.to_path_buf())
}
// Bundling pulls in audio.wav/transcript.json alongside notes.md —
// that's meeting-folder orchestration, not Markdown rendering, so
// `commands::export_meeting` handles it directly rather than here.
ExportFormat::Bundle => Err(NotesError::Export(
"bundle export is assembled in commands::export_meeting, not NotesRenderer".into(),
)),
ExportFormat::Pdf => {
let blocks = markdown_to_blocks(markdown);
pdf::render(&blocks, dest)?;
Ok(dest.to_path_buf())
}
ExportFormat::Docx => {
let blocks = markdown_to_blocks(markdown);
docx::render(&blocks, dest)?;
Ok(dest.to_path_buf())
}
}
}
}
/// One inline run of text — `bold` is only ever set on the run pulldown-cmark
/// wraps in `Strong`, which in practice means just the leading speaker-name
/// prefix ("**Alice:**") our own `to_markdown` emits, not general inline
/// formatting anywhere in a paragraph.
#[derive(Debug, Clone)]
struct Span {
text: String,
bold: bool,
}
#[derive(Debug, Clone)]
enum Block {
Heading(u8, Vec<Span>),
Paragraph(Vec<Span>),
BulletItem(Vec<Span>),
}
/// Parses notes Markdown (headings, paragraphs, bullet/task lists, bold
/// runs) into a small block model shared by the PDF and DOCX renderers —
/// good enough for what `MarkdownNotes::to_markdown` and the notes editor's
/// toolbar (bold/bullet/checkbox) actually produce, not general Markdown.
fn markdown_to_blocks(markdown: &str) -> Vec<Block> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, options);
let mut blocks = Vec::new();
let mut current: Vec<Span> = Vec::new();
let mut bold_depth = 0u32;
let mut heading_level: Option<u8> = None;
let mut in_item = false;
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
heading_level = Some(heading_level_to_u8(level));
current.clear();
}
Event::End(TagEnd::Heading(level)) => {
blocks.push(Block::Heading(
heading_level.take().unwrap_or(heading_level_to_u8(level)),
std::mem::take(&mut current),
));
}
Event::Start(Tag::Paragraph) => current.clear(),
Event::End(TagEnd::Paragraph) => {
let spans = std::mem::take(&mut current);
if in_item {
blocks.push(Block::BulletItem(spans));
} else {
blocks.push(Block::Paragraph(spans));
}
}
Event::Start(Tag::Item) => {
in_item = true;
current.clear();
}
Event::End(TagEnd::Item) => {
if !current.is_empty() {
blocks.push(Block::BulletItem(std::mem::take(&mut current)));
}
in_item = false;
}
Event::Start(Tag::Strong) => bold_depth += 1,
Event::End(TagEnd::Strong) => bold_depth = bold_depth.saturating_sub(1),
Event::Text(text) => current.push(Span {
text: text.to_string(),
bold: bold_depth > 0,
}),
Event::TaskListMarker(checked) => current.push(Span {
text: (if checked { "[x] " } else { "[ ] " }).to_string(),
bold: false,
}),
Event::SoftBreak | Event::HardBreak => current.push(Span {
text: " ".to_string(),
bold: bold_depth > 0,
}),
_ => {}
}
}
blocks
}
fn heading_level_to_u8(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
mod docx;
mod pdf;
#[cfg(test)]
mod tests {
use super::*;
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,
}
}
#[test]
fn groups_consecutive_same_speaker_segments_into_one_paragraph() {
let segments = vec![
seg(0, "S1", "Hello"),
seg(1, "S1", "world."),
seg(2, "S2", "Hi there."),
];
let md = MarkdownNotes.to_markdown(&segments, &[], None, None);
assert_eq!(md, "**S1:** Hello world.\n\n**S2:** Hi there.");
}
#[test]
fn resolves_display_name_and_falls_back_to_label() {
let segments = vec![seg(0, "S1", "Hi")];
let speakers = [SpeakerInfo {
label: "S1".to_string(),
display_name: Some("Alex".to_string()),
participant_id: None,
}];
assert_eq!(
MarkdownNotes.to_markdown(&segments, &speakers, None, None),
"**Alex:** Hi"
);
assert_eq!(
MarkdownNotes.to_markdown(&segments, &[], None, None),
"**S1:** Hi"
);
}
#[test]
fn skips_blank_segments_and_prepends_summary() {
let segments = vec![seg(0, "S1", " "), seg(1, "S1", "Real text.")];
let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone."), None);
assert_eq!(md, "## Summary\nDone.\n\n---\n\n**S1:** Real text.");
}
#[test]
fn applies_template_sections_as_an_empty_scaffold_above_the_dialogue() {
let segments = vec![seg(0, "S1", "Hi")];
let template = note_template_by_id("one-on-one").unwrap();
let md = MarkdownNotes.to_markdown(&segments, &[], None, Some(&template));
assert_eq!(md, "## Discussion\n\n## Action Items\n\n**S1:** Hi");
}
#[test]
fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() {
let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing";
let blocks = markdown_to_blocks(md);
match &blocks[0] {
Block::Heading(level, spans) => {
assert_eq!(*level, 2);
assert_eq!(spans[0].text, "Summary");
}
other => panic!("expected Heading, got {other:?}"),
}
match &blocks[1] {
Block::Paragraph(spans) => {
assert!(spans[0].bold, "speaker name prefix should be bold");
assert_eq!(spans[0].text, "Alice:");
assert!(!spans[1].bold);
}
other => panic!("expected Paragraph, got {other:?}"),
}
match &blocks[2] {
Block::BulletItem(spans) => assert_eq!(spans[0].text, "[ ] "),
other => panic!("expected BulletItem, got {other:?}"),
}
match &blocks[3] {
Block::BulletItem(spans) => assert_eq!(spans[0].text, "[x] "),
other => panic!("expected BulletItem, got {other:?}"),
}
}
// A real speaker-tagged notes sample, long enough to force a page break
// in the PDF renderer, exercising more than a one-line happy path.
fn sample_markdown() -> String {
let mut md = String::from("## Summary\n\nThis meeting covered quarterly planning.\n\n");
for i in 0..40 {
md.push_str(&format!(
"**Alice:** This is talking point number {i} about the roadmap and staffing.\n\n"
));
}
md.push_str("- [ ] Follow up with finance\n- [x] Send recap email\n");
md
}
#[test]
fn export_pdf_writes_a_valid_pdf_file() {
let dir = std::env::temp_dir().join(format!("wa-export-test-{}", uuid::Uuid::new_v4()));
let dest = dir.join("notes.pdf");
MarkdownNotes
.export(&sample_markdown(), &dest, ExportFormat::Pdf)
.expect("pdf export should succeed");
let bytes = std::fs::read(&dest).expect("pdf file should exist");
assert!(bytes.starts_with(b"%PDF-"), "missing PDF magic bytes");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn export_docx_writes_a_valid_zip_container() {
let dir = std::env::temp_dir().join(format!("wa-export-test-{}", uuid::Uuid::new_v4()));
let dest = dir.join("notes.docx");
MarkdownNotes
.export(&sample_markdown(), &dest, ExportFormat::Docx)
.expect("docx export should succeed");
let bytes = std::fs::read(&dest).expect("docx file should exist");
// .docx is a zip container — "PK\x03\x04" is the local-file-header magic.
assert!(
bytes.starts_with(b"PK\x03\x04"),
"missing zip/docx magic bytes"
);
std::fs::remove_dir_all(&dir).ok();
}
}