//! HTTP handlers for transcript analysis and contract integration. use axum::{ extract::State, http::StatusCode, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::db::{models, repository}; use crate::llm::transcript_analyzer::{ TranscriptAnalysisResult, build_analysis_prompt, calculate_speaker_stats, format_transcript_for_analysis, parse_analysis_response, }; use crate::llm::claude::{ClaudeClient, ClaudeModel, Message, MessageContent}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; // ============================================================================= // Request/Response Types // ============================================================================= /// Request to analyze a file's transcript #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct AnalyzeTranscriptRequest { /// File ID containing the transcript to analyze pub file_id: Uuid, } /// Response from transcript analysis #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct AnalyzeTranscriptResponse { pub file_id: Uuid, pub analysis: TranscriptAnalysisResult, } /// Request to create a contract from analysis #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateContractFromAnalysisRequest { /// File ID containing the analyzed transcript pub file_id: Uuid, /// Override the suggested name (optional) pub name: Option, /// Override the suggested description (optional) pub description: Option, /// Include requirements as file content (default: true) #[serde(default = "default_true")] pub include_requirements: bool, /// Include decisions as file content (default: true) #[serde(default = "default_true")] pub include_decisions: bool, /// Include action items as tasks (default: true) #[serde(default = "default_true")] pub include_action_items: bool, } fn default_true() -> bool { true } /// Response from creating contract from analysis #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateContractFromAnalysisResponse { pub contract_id: Uuid, pub contract_name: String, pub files_created: Vec, pub tasks_created: Vec, } #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct FileCreatedInfo { pub id: Uuid, pub name: String, pub file_type: String, } #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct TaskCreatedInfo { pub id: Uuid, pub name: String, } /// Request to update an existing contract from analysis #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateContractFromAnalysisRequest { /// File ID containing the transcript pub file_id: Uuid, /// Contract ID to update pub contract_id: Uuid, /// Add requirements to contract files #[serde(default = "default_true")] pub add_requirements: bool, /// Add decisions to contract files #[serde(default = "default_true")] pub add_decisions: bool, /// Create tasks from action items #[serde(default = "default_true")] pub create_tasks: bool, } /// Response from updating contract #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateContractFromAnalysisResponse { pub contract_id: Uuid, pub files_updated: Vec, pub tasks_created: Vec, pub analysis_summary: String, } // ============================================================================= // Handlers // ============================================================================= /// Analyze a file's transcript to extract requirements, decisions, and action items. #[utoipa::path( post, path = "/api/v1/listen/analyze", request_body = AnalyzeTranscriptRequest, responses( (status = 200, description = "Transcript analyzed", body = AnalyzeTranscriptResponse), (status = 400, description = "Invalid request"), (status = 401, description = "Unauthorized"), (status = 404, description = "File not found"), (status = 500, description = "Internal server error"), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Listen" )] pub async fn analyze_transcript( State(state): State, Authenticated(auth): Authenticated, Json(request): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ).into_response(); }; // Get the file let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { Ok(Some(f)) => f, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ).into_response(); } Err(e) => { tracing::error!(error = %e, "Failed to get file"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ).into_response(); } }; // Check if transcript is empty if file.transcript.is_empty() { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript to analyze")), ).into_response(); } // Analyze the transcript match analyze_transcript_internal(&file.transcript).await { Ok(analysis) => { Json(AnalyzeTranscriptResponse { file_id: request.file_id, analysis, }).into_response() } Err(e) => { tracing::error!(error = %e, "Failed to analyze transcript"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("ANALYSIS_ERROR", e)), ).into_response() } } } /// Create a new contract from an analyzed transcript. #[utoipa::path( post, path = "/api/v1/listen/create-contract", request_body = CreateContractFromAnalysisRequest, responses( (status = 201, description = "Contract created", body = CreateContractFromAnalysisResponse), (status = 400, description = "Invalid request"), (status = 401, description = "Unauthorized"), (status = 404, description = "File not found"), (status = 500, description = "Internal server error"), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Listen" )] pub async fn create_contract_from_analysis( State(state): State, Authenticated(auth): Authenticated, Json(request): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ).into_response(); }; // Get the file with transcript let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { Ok(Some(f)) => f, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ).into_response(); } Err(e) => { tracing::error!(error = %e, "Failed to get file"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ).into_response(); } }; if file.transcript.is_empty() { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), ).into_response(); } // Analyze transcript let analysis = match analyze_transcript_internal(&file.transcript).await { Ok(a) => a, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("ANALYSIS_ERROR", e)), ).into_response(); } }; // Determine contract name and description let contract_name = request.name .or(analysis.suggested_contract_name.clone()) .unwrap_or_else(|| format!("Contract from {}", file.name)); let contract_description = request.description .or(analysis.suggested_description.clone()); // Create the contract let contract_req = models::CreateContractRequest { name: contract_name.clone(), description: contract_description, contract_type: Some("specification".to_string()), initial_phase: Some("research".to_string()), autonomous_loop: None, phase_guard: None, local_only: None, auto_merge_local: None, template_id: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { Ok(c) => c, Err(e) => { tracing::error!(error = %e, "Failed to create contract"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ).into_response(); } }; let mut files_created: Vec = Vec::new(); let mut tasks_created: Vec = Vec::new(); // Create requirements file if we have requirements if request.include_requirements && !analysis.requirements.is_empty() { let body = build_requirements_body(&analysis.requirements); let file_req = models::CreateFileRequest { contract_id: contract.id, name: Some("Requirements from Transcript".to_string()), description: Some("Requirements extracted from voice transcript".to_string()), transcript: vec![], location: None, body, repo_file_path: None, contract_phase: Some("research".to_string()), }; if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { files_created.push(FileCreatedInfo { id: f.id, name: f.name, file_type: "requirements".to_string(), }); } } // Create decisions file if we have decisions if request.include_decisions && !analysis.decisions.is_empty() { let body = build_decisions_body(&analysis.decisions); let file_req = models::CreateFileRequest { contract_id: contract.id, name: Some("Decisions from Transcript".to_string()), description: Some("Decisions extracted from voice transcript".to_string()), transcript: vec![], location: None, body, repo_file_path: None, contract_phase: Some("research".to_string()), }; if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { files_created.push(FileCreatedInfo { id: f.id, name: f.name, file_type: "decisions".to_string(), }); } } // Create tasks from action items if request.include_action_items && !analysis.action_items.is_empty() { for item in &analysis.action_items { let task_req = models::CreateTaskRequest { contract_id: Some(contract.id), name: truncate_for_name(&item.text, 100), description: Some(format!("Action item from transcript (Speaker: {})", item.speaker)), plan: item.text.clone(), repository_url: None, base_branch: None, target_branch: None, parent_task_id: None, target_repo_path: None, completion_action: None, continue_from_task_id: None, copy_files: None, is_supervisor: false, checkpoint_sha: None, priority: match item.priority.as_deref() { Some("high") => 10, Some("medium") => 5, _ => 0, }, merge_mode: None, branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, directive_step_id: None, }; if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { tasks_created.push(TaskCreatedInfo { id: t.id, name: t.name, }); } } } ( StatusCode::CREATED, Json(CreateContractFromAnalysisResponse { contract_id: contract.id, contract_name, files_created, tasks_created, }), ).into_response() } /// Update an existing contract with information from transcript analysis. #[utoipa::path( post, path = "/api/v1/listen/update-contract", request_body = UpdateContractFromAnalysisRequest, responses( (status = 200, description = "Contract updated", body = UpdateContractFromAnalysisResponse), (status = 400, description = "Invalid request"), (status = 401, description = "Unauthorized"), (status = 404, description = "File or contract not found"), (status = 500, description = "Internal server error"), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Listen" )] pub async fn update_contract_from_analysis( State(state): State, Authenticated(auth): Authenticated, Json(request): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ).into_response(); }; // Get the file let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { Ok(Some(f)) => f, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ).into_response(); } Err(e) => { tracing::error!(error = %e, "Failed to get file"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ).into_response(); } }; // Verify contract exists let _contract = match repository::get_contract_for_owner(pool, request.contract_id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ).into_response(); } Err(e) => { tracing::error!(error = %e, "Failed to get contract"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ).into_response(); } }; if file.transcript.is_empty() { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), ).into_response(); } // Analyze transcript let analysis = match analyze_transcript_internal(&file.transcript).await { Ok(a) => a, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("ANALYSIS_ERROR", e)), ).into_response(); } }; let mut files_updated: Vec = Vec::new(); let mut tasks_created: Vec = Vec::new(); // Create or update requirements file if request.add_requirements && !analysis.requirements.is_empty() { let body = build_requirements_body(&analysis.requirements); let file_req = models::CreateFileRequest { contract_id: request.contract_id, name: Some(format!("Requirements from {}", file.name)), description: Some("Requirements extracted from voice transcript".to_string()), transcript: vec![], location: None, body, repo_file_path: None, contract_phase: None, }; if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { files_updated.push(f.id); } } // Create or update decisions file if request.add_decisions && !analysis.decisions.is_empty() { let body = build_decisions_body(&analysis.decisions); let file_req = models::CreateFileRequest { contract_id: request.contract_id, name: Some(format!("Decisions from {}", file.name)), description: Some("Decisions extracted from voice transcript".to_string()), transcript: vec![], location: None, body, repo_file_path: None, contract_phase: None, }; if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { files_updated.push(f.id); } } // Create tasks from action items if request.create_tasks && !analysis.action_items.is_empty() { for item in &analysis.action_items { let task_req = models::CreateTaskRequest { contract_id: Some(request.contract_id), name: truncate_for_name(&item.text, 100), description: Some(format!("Action item from {} (Speaker: {})", file.name, item.speaker)), plan: item.text.clone(), repository_url: None, base_branch: None, target_branch: None, parent_task_id: None, target_repo_path: None, completion_action: None, continue_from_task_id: None, copy_files: None, is_supervisor: false, checkpoint_sha: None, priority: 0, merge_mode: None, branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, directive_step_id: None, }; if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { tasks_created.push(TaskCreatedInfo { id: t.id, name: t.name, }); } } } let summary = format!( "Extracted {} requirements, {} decisions, {} action items from transcript", analysis.requirements.len(), analysis.decisions.len(), analysis.action_items.len() ); Json(UpdateContractFromAnalysisResponse { contract_id: request.contract_id, files_updated, tasks_created, analysis_summary: summary, }).into_response() } // ============================================================================= // Helper Functions // ============================================================================= /// Analyze transcript using Claude async fn analyze_transcript_internal( transcript: &[models::TranscriptEntry], ) -> Result { let transcript_text = format_transcript_for_analysis(transcript); let speaker_stats = calculate_speaker_stats(transcript); let prompt = build_analysis_prompt(&transcript_text); // Create Claude client let client = ClaudeClient::from_env(ClaudeModel::Sonnet) .map_err(|e| format!("Failed to create Claude client: {}", e))?; // Call Claude API with empty tools to make a simple chat call let messages = vec![Message { role: "user".to_string(), content: MessageContent::Text(prompt), }]; let result = client.chat_with_tools(messages, &[]).await .map_err(|e| format!("Claude API error: {}", e))?; // Parse the response let content = result.content.ok_or_else(|| "No response content from Claude".to_string())?; parse_analysis_response(&content, speaker_stats) } /// Build file body elements from requirements fn build_requirements_body(requirements: &[crate::llm::transcript_analyzer::ExtractedRequirement]) -> Vec { let mut body = vec![ models::BodyElement::Heading { level: 1, text: "Requirements".to_string(), }, ]; // Group by category if available let mut functional = Vec::new(); let mut technical = Vec::new(); let mut other = Vec::new(); for req in requirements { match req.category.as_deref() { Some("functional") => functional.push(req), Some("technical") => technical.push(req), _ => other.push(req), } } if !functional.is_empty() { body.push(models::BodyElement::Heading { level: 2, text: "Functional Requirements".to_string(), }); body.push(models::BodyElement::List { ordered: false, items: functional.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), }); } if !technical.is_empty() { body.push(models::BodyElement::Heading { level: 2, text: "Technical Requirements".to_string(), }); body.push(models::BodyElement::List { ordered: false, items: technical.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), }); } if !other.is_empty() { body.push(models::BodyElement::Heading { level: 2, text: "Other Requirements".to_string(), }); body.push(models::BodyElement::List { ordered: false, items: other.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), }); } body } /// Build file body elements from decisions fn build_decisions_body(decisions: &[crate::llm::transcript_analyzer::ExtractedDecision]) -> Vec { let mut body = vec![ models::BodyElement::Heading { level: 1, text: "Decisions".to_string(), }, ]; let items: Vec = decisions.iter().map(|d| { let context = d.context.as_ref().map(|c| format!(" (Context: {})", c)).unwrap_or_default(); format!("**{}**: {}{}", d.speaker, d.text, context) }).collect(); body.push(models::BodyElement::List { ordered: true, items, }); body } /// Truncate text to fit as a task name fn truncate_for_name(text: &str, max_len: usize) -> String { if text.len() <= max_len { text.to_string() } else { format!("{}...", &text[..max_len - 3]) } }