diff options
| author | soryu <soryu@soryu.co> | 2026-01-14 21:38:29 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 01:30:02 +0000 |
| commit | 3d7a4e64a6c9dfaaf715993d23ea7c93c1094b9d (patch) | |
| tree | eefe02d38861bec0c7af60f929e422683b2510c6 | |
| parent | f1a15be70b176f80536d4a6764bd2c09861593ef (diff) | |
| download | soryu-3d7a4e64a6c9dfaaf715993d23ea7c93c1094b9d.tar.gz soryu-3d7a4e64a6c9dfaaf715993d23ea7c93c1094b9d.zip | |
feat(listen): add transcript analysis API endpoints
Adds three new endpoints under /api/v1/listen/:
- POST /analyze - Analyze a file's transcript for requirements, decisions, action items
- POST /create-contract - Create a new contract from analyzed transcript
- POST /update-contract - Update an existing contract with extracted information
These endpoints bridge the Listen (voice transcription) system with the Contract
system, enabling voice-to-contract workflows.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 674 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 6 |
3 files changed, 680 insertions, 1 deletions
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 8c2cb0c..0ce6c85 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -15,5 +15,6 @@ pub mod mesh_merge; pub mod mesh_supervisor; pub mod mesh_ws; pub mod templates; +pub mod transcript_analysis; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs new file mode 100644 index 0000000..1cb5506 --- /dev/null +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -0,0 +1,674 @@ +//! 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<String>, + /// Override the suggested description (optional) + pub description: Option<String>, + /// 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<FileCreatedInfo>, + pub tasks_created: Vec<TaskCreatedInfo>, +} + +#[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<Uuid>, + pub tasks_created: Vec<TaskCreatedInfo>, + 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<SharedState>, + Authenticated(auth): Authenticated, + Json(request): Json<AnalyzeTranscriptRequest>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Json(request): Json<CreateContractFromAnalysisRequest>, +) -> 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, + initial_phase: Some("research".to_string()), + }; + + 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<FileCreatedInfo> = Vec::new(); + let mut tasks_created: Vec<TaskCreatedInfo> = 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: 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, + }; + + 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<SharedState>, + Authenticated(auth): Authenticated, + Json(request): Json<UpdateContractFromAnalysisRequest>, +) -> 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<Uuid> = Vec::new(); + let mut tasks_created: Vec<TaskCreatedInfo> = 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: 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, + }; + + 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<TranscriptAnalysisResult, String> { + 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<models::BodyElement> { + 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<models::BodyElement> { + let mut body = vec![ + models::BodyElement::Heading { + level: 1, + text: "Decisions".to_string(), + }, + ]; + + let items: Vec<String> = 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]) + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 568b287..a4a01e0 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, templates, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -44,6 +44,10 @@ pub fn make_router(state: SharedState) -> Router { // API v1 routes let api_v1 = Router::new() .route("/listen", get(listen::websocket_handler)) + // Listen/transcript analysis endpoints + .route("/listen/analyze", post(transcript_analysis::analyze_transcript)) + .route("/listen/create-contract", post(transcript_analysis::create_contract_from_analysis)) + .route("/listen/update-contract", post(transcript_analysis::update_contract_from_analysis)) .route("/files/subscribe", get(file_ws::file_subscription_handler)) .route("/files", get(files::list_files).post(files::create_file)) .route( |
