diff options
| author | soryu <soryu@soryu.co> | 2026-01-14 21:34:00 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 01:30:02 +0000 |
| commit | f1a15be70b176f80536d4a6764bd2c09861593ef (patch) | |
| tree | 7ffe5fc55f304b49386efdd8876a399ec52f8143 | |
| parent | 8e90252284bb069994c708badcc6d113d6d32f94 (diff) | |
| download | soryu-f1a15be70b176f80536d4a6764bd2c09861593ef.tar.gz soryu-f1a15be70b176f80536d4a6764bd2c09861593ef.zip | |
feat(transcript): add transcript analyzer module for extracting requirements and decisions
Adds a new transcript_analyzer module that:
- Defines types for extracted requirements, decisions, and action items
- Provides functions to format transcripts for LLM analysis
- Calculates speaker statistics from transcript entries
- Builds analysis prompts and parses LLM responses
- Includes unit tests for core functionality
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/src/llm/mod.rs | 6 | ||||
| -rw-r--r-- | makima/src/llm/transcript_analyzer.rs | 292 |
2 files changed, 298 insertions, 0 deletions
diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs index da8c0a4..c4f8e50 100644 --- a/makima/src/llm/mod.rs +++ b/makima/src/llm/mod.rs @@ -9,6 +9,7 @@ pub mod phase_guidance; pub mod task_output; pub mod templates; pub mod tools; +pub mod transcript_analyzer; pub use claude::{ClaudeClient, ClaudeModel}; pub use contract_tools::{ @@ -32,6 +33,11 @@ pub use tools::{ execute_tool_call, Tool, ToolCall, ToolResult, UserAnswer, UserQuestion, VersionToolRequest, AVAILABLE_TOOLS, }; +pub use transcript_analyzer::{ + TranscriptAnalysisResult, ExtractedRequirement, ExtractedDecision, + ExtractedActionItem, SpeakerStats, format_transcript_for_analysis, + calculate_speaker_stats, build_analysis_prompt, parse_analysis_response, +}; /// Available LLM providers and models #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/makima/src/llm/transcript_analyzer.rs b/makima/src/llm/transcript_analyzer.rs new file mode 100644 index 0000000..82aa69d --- /dev/null +++ b/makima/src/llm/transcript_analyzer.rs @@ -0,0 +1,292 @@ +//! Transcript analyzer for extracting requirements, decisions, and action items. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use crate::db::models::TranscriptEntry; + +/// An extracted requirement from the transcript +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedRequirement { + pub text: String, + pub speaker: String, + pub timestamp: f32, + pub confidence: f32, + pub category: Option<String>, // functional, technical, non-functional, business +} + +/// An extracted decision from the transcript +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedDecision { + pub text: String, + pub speaker: String, + pub timestamp: f32, + pub confidence: f32, + pub context: Option<String>, +} + +/// An extracted action item from the transcript +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedActionItem { + pub text: String, + pub speaker: String, + pub timestamp: f32, + pub assignee: Option<String>, + pub priority: Option<String>, +} + +/// Result of transcript analysis +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptAnalysisResult { + pub requirements: Vec<ExtractedRequirement>, + pub decisions: Vec<ExtractedDecision>, + pub action_items: Vec<ExtractedActionItem>, + pub key_topics: Vec<String>, + pub suggested_contract_name: Option<String>, + pub suggested_description: Option<String>, + pub speaker_summary: Vec<SpeakerStats>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SpeakerStats { + pub speaker: String, + pub word_count: usize, + pub speaking_time_seconds: f32, + pub contribution_percentage: f32, +} + +/// Format transcript entries into readable text for LLM analysis +pub fn format_transcript_for_analysis(entries: &[TranscriptEntry]) -> String { + entries + .iter() + .map(|e| format!("[{:.1}s] {}: {}", e.start, e.speaker, e.text)) + .collect::<Vec<_>>() + .join("\n") +} + +/// Calculate speaker statistics from transcript +pub fn calculate_speaker_stats(entries: &[TranscriptEntry]) -> Vec<SpeakerStats> { + use std::collections::HashMap; + + let mut stats: HashMap<String, (usize, f32)> = HashMap::new(); + + for entry in entries { + let word_count = entry.text.split_whitespace().count(); + let duration = entry.end - entry.start; + + let (count, time) = stats.entry(entry.speaker.clone()).or_insert((0, 0.0)); + *count += word_count; + *time += duration; + } + + let total_words: usize = stats.values().map(|(c, _)| c).sum(); + let total_time: f32 = stats.values().map(|(_, t)| t).sum(); + + // Suppress unused variable warning + let _ = total_time; + + stats + .into_iter() + .map(|(speaker, (word_count, speaking_time))| SpeakerStats { + speaker, + word_count, + speaking_time_seconds: speaking_time, + contribution_percentage: if total_words > 0 { + (word_count as f32 / total_words as f32) * 100.0 + } else { + 0.0 + }, + }) + .collect() +} + +/// Build the analysis prompt for the LLM +pub fn build_analysis_prompt(transcript_text: &str) -> String { + format!(r#"Analyze this meeting/conversation transcript and extract structured information. + +TRANSCRIPT: +{} + +Extract the following information in JSON format: + +1. **Requirements**: Statements where someone expresses a need, want, or must-have. Look for phrases like: + - "we need to...", "it should...", "must have...", "requirement is..." + - "the system should...", "users need to be able to..." + +2. **Decisions**: Explicit decisions made during the conversation. Look for: + - "let's go with...", "we decided...", "we'll use...", "agreed to..." + - "the decision is...", "we're going with..." + +3. **Action Items**: Tasks or todos mentioned. Look for: + - "someone needs to...", "we should...", "next step is..." + - "I'll do...", "can you...", "TODO:..." + +4. **Key Topics**: Main subjects discussed + +5. **Suggested Contract Name**: A short name (3-5 words) that captures the main goal + +6. **Suggested Description**: A 1-2 sentence description of what should be built/done + +Return your analysis as JSON with this structure: +{{ + "requirements": [ + {{"text": "...", "speaker": "Speaker X", "timestamp": 12.5, "confidence": 0.9, "category": "functional"}} + ], + "decisions": [ + {{"text": "...", "speaker": "Speaker X", "timestamp": 45.2, "confidence": 0.85, "context": "..."}} + ], + "action_items": [ + {{"text": "...", "speaker": "Speaker X", "timestamp": 78.0, "assignee": null, "priority": "high"}} + ], + "key_topics": ["topic1", "topic2"], + "suggested_contract_name": "...", + "suggested_description": "..." +}} + +Be conservative - only extract items with high confidence. If nothing is found for a category, return an empty array."#, transcript_text) +} + +/// Parse LLM response into analysis result +pub fn parse_analysis_response(response: &str, speaker_stats: Vec<SpeakerStats>) -> Result<TranscriptAnalysisResult, String> { + // Try to extract JSON from the response (it might be wrapped in markdown code blocks) + let json_str = extract_json_from_response(response)?; + + #[derive(Deserialize)] + struct LlmResponse { + requirements: Option<Vec<ExtractedRequirement>>, + decisions: Option<Vec<ExtractedDecision>>, + action_items: Option<Vec<ExtractedActionItem>>, + key_topics: Option<Vec<String>>, + suggested_contract_name: Option<String>, + suggested_description: Option<String>, + } + + let parsed: LlmResponse = serde_json::from_str(&json_str) + .map_err(|e| format!("Failed to parse LLM response as JSON: {}", e))?; + + Ok(TranscriptAnalysisResult { + requirements: parsed.requirements.unwrap_or_default(), + decisions: parsed.decisions.unwrap_or_default(), + action_items: parsed.action_items.unwrap_or_default(), + key_topics: parsed.key_topics.unwrap_or_default(), + suggested_contract_name: parsed.suggested_contract_name, + suggested_description: parsed.suggested_description, + speaker_summary: speaker_stats, + }) +} + +/// Extract JSON from LLM response (handles markdown code blocks) +fn extract_json_from_response(response: &str) -> Result<String, String> { + // Try to find JSON in code blocks first + if let Some(start) = response.find("```json") { + if let Some(end) = response[start..].find("```\n").or_else(|| response[start..].rfind("```")) { + let json_start = start + 7; // Skip "```json" + let json_end = start + end; + if json_end > json_start { + return Ok(response[json_start..json_end].trim().to_string()); + } + } + } + + // Try plain code blocks + if let Some(start) = response.find("```") { + let after_start = start + 3; + if let Some(end) = response[after_start..].find("```") { + let json_str = &response[after_start..after_start + end]; + // Skip language identifier if present + let json_str = if let Some(newline) = json_str.find('\n') { + &json_str[newline + 1..] + } else { + json_str + }; + return Ok(json_str.trim().to_string()); + } + } + + // Try to find raw JSON (starts with { or [) + if let Some(start) = response.find('{') { + if let Some(end) = response.rfind('}') { + if end > start { + return Ok(response[start..=end].to_string()); + } + } + } + + Err("Could not find JSON in LLM response".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_transcript() { + let entries = vec![ + TranscriptEntry { + id: "1".to_string(), + speaker: "Speaker 0".to_string(), + start: 0.0, + end: 2.5, + text: "Hello world".to_string(), + is_final: true, + }, + ]; + + let formatted = format_transcript_for_analysis(&entries); + assert!(formatted.contains("[0.0s] Speaker 0: Hello world")); + } + + #[test] + fn test_speaker_stats() { + let entries = vec![ + TranscriptEntry { + id: "1".to_string(), + speaker: "Speaker 0".to_string(), + start: 0.0, + end: 5.0, + text: "One two three four five".to_string(), + is_final: true, + }, + TranscriptEntry { + id: "2".to_string(), + speaker: "Speaker 1".to_string(), + start: 5.0, + end: 10.0, + text: "Six seven eight nine ten".to_string(), + is_final: true, + }, + ]; + + let stats = calculate_speaker_stats(&entries); + assert_eq!(stats.len(), 2); + + for s in &stats { + assert_eq!(s.word_count, 5); + assert_eq!(s.speaking_time_seconds, 5.0); + assert!((s.contribution_percentage - 50.0).abs() < 0.1); + } + } + + #[test] + fn test_extract_json_from_response() { + let response = r#"Here is the analysis: +```json +{"key": "value"} +``` +Done."#; + + let json = extract_json_from_response(response).unwrap(); + assert_eq!(json, r#"{"key": "value"}"#); + } + + #[test] + fn test_extract_raw_json() { + let response = r#"Analysis: {"key": "value"}"#; + let json = extract_json_from_response(response).unwrap(); + assert_eq!(json, r#"{"key": "value"}"#); + } +} |
