summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-14 21:38:29 +0000
committersoryu <soryu@soryu.co>2026-01-15 01:30:02 +0000
commit3d7a4e64a6c9dfaaf715993d23ea7c93c1094b9d (patch)
treeeefe02d38861bec0c7af60f929e422683b2510c6
parentf1a15be70b176f80536d4a6764bd2c09861593ef (diff)
downloadsoryu-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.rs1
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs674
-rw-r--r--makima/src/server/mod.rs6
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(