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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user