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.
426 lines
15 KiB
Rust
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();
|
|
}
|
|
}
|