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.
This commit is contained in:
iamdoubz
2026-07-02 11:25:34 -05:00
parent 67354642ab
commit f63076ad06
+79 -4
View File
@@ -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<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)
@@ -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";