diff options
| author | soryu <soryu@soryu.co> | 2026-01-14 21:43:36 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 01:30:02 +0000 |
| commit | eae8e698e89d7e5c8dc5bcdb2dcef61f25295515 (patch) | |
| tree | 6007278fbd87d8a74f0d7de2dcae09a0a69e4fda | |
| parent | 3d7a4e64a6c9dfaaf715993d23ea7c93c1094b9d (diff) | |
| download | soryu-eae8e698e89d7e5c8dc5bcdb2dcef61f25295515.tar.gz soryu-eae8e698e89d7e5c8dc5bcdb2dcef61f25295515.zip | |
feat(contract-tools): add transcript analysis tools for contract chat
Adds two new tools to contract_tools:
- analyze_transcript: Analyze a file's transcript and return structured results
- create_contract_from_transcript: Create a new contract from transcript analysis
These tools enable voice-to-contract workflows directly from the contract chat interface.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/src/llm/contract_tools.rs | 113 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 323 |
2 files changed, 436 insertions, 0 deletions
diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs index 0d6f9be..7a3d09a 100644 --- a/makima/src/llm/contract_tools.rs +++ b/makima/src/llm/contract_tools.rs @@ -407,6 +407,57 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L "required": ["questions"] }), }, + // ============================================================================= + // Transcript Analysis Tools + // ============================================================================= + Tool { + name: "analyze_transcript".to_string(), + description: "Analyze a file's transcript to extract requirements, decisions, and action items. Returns structured analysis including speaker statistics.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "ID of the file containing the transcript to analyze" + } + }, + "required": ["file_id"] + }), + }, + Tool { + name: "create_contract_from_transcript".to_string(), + description: "Create a new contract from an analyzed transcript. Will extract requirements, decisions, and action items and create appropriate files and tasks in the new contract.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "ID of the file containing the transcript" + }, + "name": { + "type": "string", + "description": "Optional name for the contract (otherwise auto-generated from analysis)" + }, + "description": { + "type": "string", + "description": "Optional description for the contract (otherwise auto-generated)" + }, + "include_requirements": { + "type": "boolean", + "description": "Whether to create a requirements file (default: true)" + }, + "include_decisions": { + "type": "boolean", + "description": "Whether to create a decisions file (default: true)" + }, + "include_action_items": { + "type": "boolean", + "description": "Whether to create tasks from action items (default: true)" + } + }, + "required": ["file_id"] + }), + }, ] }); @@ -475,6 +526,17 @@ pub enum ContractToolRequest { task_id: Uuid, section_title: Option<String>, }, + + // Transcript analysis + AnalyzeTranscript { file_id: Uuid }, + CreateContractFromTranscript { + file_id: Uuid, + name: Option<String>, + description: Option<String>, + include_requirements: bool, + include_decisions: bool, + include_action_items: bool, + }, } /// Task definition for chained task creation @@ -540,6 +602,10 @@ pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolEx // Interactive tools "ask_user" => parse_ask_user(call), + // Transcript analysis tools + "analyze_transcript" => parse_analyze_transcript(call), + "create_contract_from_transcript" => parse_create_contract_from_transcript(call), + _ => ContractToolExecutionResult { success: false, message: format!("Unknown contract tool: {}", call.name), @@ -1070,6 +1136,53 @@ fn parse_update_file_from_task(call: &super::tools::ToolCall) -> ContractToolExe } // ============================================================================= +// Transcript Analysis Tool Parsing +// ============================================================================= + +fn parse_analyze_transcript(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let file_id = parse_uuid_arg(call, "file_id"); + let Some(file_id) = file_id else { + return error_result("Missing or invalid required parameter: file_id"); + }; + + ContractToolExecutionResult { + success: true, + message: "Analyzing transcript...".to_string(), + data: None, + request: Some(ContractToolRequest::AnalyzeTranscript { file_id }), + pending_questions: None, + } +} + +fn parse_create_contract_from_transcript(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let file_id = parse_uuid_arg(call, "file_id"); + let Some(file_id) = file_id else { + return error_result("Missing or invalid required parameter: file_id"); + }; + + let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); + let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + let include_requirements = call.arguments.get("include_requirements").and_then(|v| v.as_bool()).unwrap_or(true); + let include_decisions = call.arguments.get("include_decisions").and_then(|v| v.as_bool()).unwrap_or(true); + let include_action_items = call.arguments.get("include_action_items").and_then(|v| v.as_bool()).unwrap_or(true); + + ContractToolExecutionResult { + success: true, + message: "Creating contract from transcript...".to_string(), + data: None, + request: Some(ContractToolRequest::CreateContractFromTranscript { + file_id, + name, + description, + include_requirements, + include_decisions, + include_action_items, + }), + pending_questions: None, + } +} + +// ============================================================================= // Helper Functions // ============================================================================= diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index d090999..557e27b 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -25,6 +25,8 @@ use crate::llm::{ groq::{GroqClient, GroqError, Message, ToolCallResponse}, parse_contract_tool_call, templates_for_phase, ContractToolRequest, FileInfo, LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, + format_transcript_for_analysis, calculate_speaker_stats, + build_analysis_prompt, parse_analysis_response, }; use crate::server::auth::Authenticated; use crate::server::state::{DaemonCommand, SharedState}; @@ -2201,6 +2203,327 @@ async fn handle_contract_request( }, } } + + // ============================================================================= + // Transcript Analysis Handlers + // ============================================================================= + + ContractToolRequest::AnalyzeTranscript { file_id } => { + // Get the file + let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { + Ok(Some(f)) => f, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "File not found".to_string(), + data: None, + }; + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }; + } + }; + + if file.transcript.is_empty() { + return ContractRequestResult { + success: false, + message: "File has no transcript to analyze".to_string(), + data: None, + }; + } + + // Format and analyze + let transcript_text = format_transcript_for_analysis(&file.transcript); + let speaker_stats = calculate_speaker_stats(&file.transcript); + let prompt = build_analysis_prompt(&transcript_text); + + // Call Claude for analysis + let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { + Ok(c) => c, + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to create Claude client: {}", e), + data: None, + }; + } + }; + + let claude_messages = vec![claude::Message { + role: "user".to_string(), + content: claude::MessageContent::Text(prompt), + }]; + + match client.chat_with_tools(claude_messages, &[]).await { + Ok(result) => { + let response_content = result.content.unwrap_or_default(); + match parse_analysis_response(&response_content, speaker_stats) { + Ok(analysis) => { + ContractRequestResult { + success: true, + message: format!( + "Analysis complete: {} requirements, {} decisions, {} action items", + analysis.requirements.len(), + analysis.decisions.len(), + analysis.action_items.len() + ), + data: Some(json!({ + "analysis": analysis + })), + } + } + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to parse analysis: {}", e), + data: None, + } + } + } + Err(e) => ContractRequestResult { + success: false, + message: format!("Claude API error: {}", e), + data: None, + } + } + } + + ContractToolRequest::CreateContractFromTranscript { + file_id, name, description, include_requirements, include_decisions, include_action_items + } => { + // Get file + let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { + Ok(Some(f)) => f, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "File not found".to_string(), + data: None, + }; + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }; + } + }; + + if file.transcript.is_empty() { + return ContractRequestResult { + success: false, + message: "File has no transcript".to_string(), + data: None, + }; + } + + // Analyze transcript + let transcript_text = format_transcript_for_analysis(&file.transcript); + let speaker_stats = calculate_speaker_stats(&file.transcript); + let prompt = build_analysis_prompt(&transcript_text); + + let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { + Ok(c) => c, + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to create Claude client: {}", e), + data: None, + }; + } + }; + + let claude_messages = vec![claude::Message { + role: "user".to_string(), + content: claude::MessageContent::Text(prompt), + }]; + + let analysis = match client.chat_with_tools(claude_messages, &[]).await { + Ok(result) => { + let response_content = result.content.unwrap_or_default(); + match parse_analysis_response(&response_content, speaker_stats) { + Ok(a) => a, + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to parse analysis: {}", e), + data: None, + }; + } + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Claude API error: {}", e), + data: None, + }; + } + }; + + // Create contract + let contract_name = name + .or(analysis.suggested_contract_name.clone()) + .unwrap_or_else(|| format!("Contract from {}", file.name)); + let contract_description = description.or(analysis.suggested_description.clone()); + + let contract_req = crate::db::models::CreateContractRequest { + name: contract_name.clone(), + description: contract_description, + initial_phase: Some("research".to_string()), + }; + + let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { + Ok(c) => c, + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to create contract: {}", e), + data: None, + }; + } + }; + + let mut files_created = 0; + let mut tasks_created = 0; + + // Create requirements file if requested and there are requirements + if include_requirements && !analysis.requirements.is_empty() { + let requirements_items: Vec<String> = analysis.requirements + .iter() + .map(|req| format!("[{}] {}", req.speaker, req.text)) + .collect(); + + let body: Vec<crate::db::models::BodyElement> = vec![ + crate::db::models::BodyElement::Heading { + level: 1, + text: "Requirements".to_string(), + }, + crate::db::models::BodyElement::Paragraph { + text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()), + }, + crate::db::models::BodyElement::Heading { + level: 2, + text: "Extracted Requirements".to_string(), + }, + crate::db::models::BodyElement::List { + ordered: false, + items: requirements_items, + }, + ]; + + let create_req = crate::db::models::CreateFileRequest { + contract_id: contract.id, + name: Some("Requirements".to_string()), + description: Some("Requirements extracted from transcript analysis".to_string()), + body, + transcript: Vec::new(), + location: None, + repo_file_path: None, + contract_phase: Some("specify".to_string()), + }; + + if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { + files_created += 1; + } + } + + // Create decisions file if requested and there are decisions + if include_decisions && !analysis.decisions.is_empty() { + let decisions_items: Vec<String> = analysis.decisions + .iter() + .map(|dec| format!("[{}] {}", dec.speaker, dec.text)) + .collect(); + + let body: Vec<crate::db::models::BodyElement> = vec![ + crate::db::models::BodyElement::Heading { + level: 1, + text: "Decisions".to_string(), + }, + crate::db::models::BodyElement::Paragraph { + text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()), + }, + crate::db::models::BodyElement::Heading { + level: 2, + text: "Recorded Decisions".to_string(), + }, + crate::db::models::BodyElement::List { + ordered: false, + items: decisions_items, + }, + ]; + + let create_req = crate::db::models::CreateFileRequest { + contract_id: contract.id, + name: Some("Decisions".to_string()), + description: Some("Decisions extracted from transcript analysis".to_string()), + body, + transcript: Vec::new(), + location: None, + repo_file_path: None, + contract_phase: Some("research".to_string()), + }; + + if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { + files_created += 1; + } + } + + // Create tasks from action items if requested + if include_action_items && !analysis.action_items.is_empty() { + for item in &analysis.action_items { + let task_req = CreateTaskRequest { + contract_id: contract.id, + name: item.text.chars().take(100).collect(), + description: Some(format!("Action item from: {}", item.speaker)), + plan: item.text.clone(), + parent_task_id: None, + repository_url: None, + base_branch: None, + target_branch: None, + merge_mode: None, + priority: match item.priority.as_deref() { + Some("high") => 10, + Some("medium") => 5, + _ => 0, + }, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + is_supervisor: false, + checkpoint_sha: None, + }; + + if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { + tasks_created += 1; + } + } + } + + ContractRequestResult { + success: true, + message: format!( + "Created contract '{}' with {} files and {} tasks from transcript analysis", + contract_name, files_created, tasks_created + ), + data: Some(json!({ + "contractId": contract.id, + "contractName": contract_name, + "filesCreated": files_created, + "tasksCreated": tasks_created, + "analysis": { + "requirementsCount": analysis.requirements.len(), + "decisionsCount": analysis.decisions.len(), + "actionItemsCount": analysis.action_items.len() + } + })), + } + } } } |
