From f63076ad066f6e3efb333383b588e6cc4557e244 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:34 -0500 Subject: [PATCH] feat(templates): NoteTemplate catalog + apply in to_markdown (T8.1, FR-NOTE-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/notes/mod.rs | 83 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/notes/mod.rs b/src-tauri/src/notes/mod.rs index ea10fff..756c2ce 100644 --- a/src-tauri/src/notes/mod.rs +++ b/src-tauri/src/notes/mod.rs @@ -5,6 +5,7 @@ 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)] @@ -25,13 +26,73 @@ pub enum ExportFormat { 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, +} + +/// 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 { + 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 { + 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) @@ -46,8 +107,14 @@ impl NotesRenderer for MarkdownNotes { 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() { @@ -246,7 +313,7 @@ mod tests { seg(1, "S1", "world."), seg(2, "S2", "Hi there."), ]; - let md = MarkdownNotes.to_markdown(&segments, &[], None); + let md = MarkdownNotes.to_markdown(&segments, &[], None, None); assert_eq!(md, "**S1:** Hello world.\n\n**S2:** Hi there."); } @@ -259,11 +326,11 @@ mod tests { participant_id: None, }]; assert_eq!( - MarkdownNotes.to_markdown(&segments, &speakers, None), + MarkdownNotes.to_markdown(&segments, &speakers, None, None), "**Alex:** Hi" ); assert_eq!( - MarkdownNotes.to_markdown(&segments, &[], None), + MarkdownNotes.to_markdown(&segments, &[], None, None), "**S1:** Hi" ); } @@ -271,10 +338,18 @@ mod tests { #[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.")); + 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";