summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-14 21:43:36 +0000
committersoryu <soryu@soryu.co>2026-01-15 01:30:02 +0000
commiteae8e698e89d7e5c8dc5bcdb2dcef61f25295515 (patch)
tree6007278fbd87d8a74f0d7de2dcae09a0a69e4fda
parent3d7a4e64a6c9dfaaf715993d23ea7c93c1094b9d (diff)
downloadsoryu-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.rs113
-rw-r--r--makima/src/server/handlers/contract_chat.rs323
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()
+ }
+ })),
+ }
+ }
}
}