summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/transcript_analysis.rs
blob: 0a6ac7ff48193e5d3aacbcc6591adf07454f369a (plain) (tree)



















































































































































































































































































                                                                                                    
                                                         
                                                    
                              
                          
                         

                               

































































                                                                                                         
                                               











                                                                                                      
                                   






                                                          

                                            
                                                                               






















































































































































                                                                                                              
                                                       











                                                                                                         
                                   


                                     

                                            
                                                                               















































































































































                                                                                                                                
//! 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,
        contract_type: Some("specification".to_string()),
        initial_phase: Some("research".to_string()),
        autonomous_loop: None,
        phase_guard: None,
        local_only: None,
        red_team_enabled: None,
        red_team_prompt: 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<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: 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,
                is_red_team: 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
            };

            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: 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,
                is_red_team: 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
            };

            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])
    }
}