//! 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,
};
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,
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,
};
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,
checkpoint_sha: None,
priority: 0,
merge_mode: None,
branched_from_task_id: None,
conversation_history: 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])
}
}