summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/transcript_analysis.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-01 23:56:51 +0100
committerGitHub <noreply@github.com>2026-05-01 23:56:51 +0100
commite11759447b1ac00becfb1e979e488f7f9c9cf478 (patch)
treef8a58368de3f6dda3f2f5c1af34e869a0e714205 /makima/src/server/handlers/transcript_analysis.rs
parent80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (diff)
downloadsoryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.tar.gz
soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.zip
chore(cleanup): Phase 5 contracts removal + tmp directive + 30-day expiry + scroll fix (#118)
Sweeping cleanup across the surface and the wire. Net: -14k LOC of legacy contracts code, plus the tmp/scroll/UX fixes the user asked for. ## Sidebar/editor independent scroll Replace `height: calc(100vh - 80px)` (which assumed an 80px masthead and quietly clipped or pushed the whole page below the fold when the masthead was taller) with `h-screen + overflow-hidden` on the page root and proper `flex-1 min-h-0` sizing on `<main>`. Sidebar and editor pane now manage their own scroll independently; the page itself never scrolls. Same fix in /tmp/:taskId. ## tmp directive — real backing for orphans/ephemerals New migration `20260501100000_tmp_directive_and_clear_orphans.sql`: * Adds `directives.is_tmp` BOOLEAN NOT NULL DEFAULT false. * Partial unique index `(owner_id) WHERE is_tmp` — at most ONE tmp directive per owner. * Hard-deletes every existing orphan task (`directive_id IS NULL`). Per the user spec: "ALSO there are TOO MANY old tasks in tmp, we need to remove all of them as well." New repository helpers: * `get_or_create_tmp_directive(pool, owner_id) -> Directive` INSERT ON CONFLICT DO NOTHING + fallback SELECT, race-safe. * `list_all_tmp_directives` — drives the expiry sweep. * `delete_expired_tmp_tasks(tmp_directive_id) -> u64`. * `list_tmp_tasks_for_owner` (replaces `list_orphan_tasks_for_owner`). `mesh::create_task`: every top-level task must have a directive. If a caller doesn't supply `directive_id` and isn't a subtask, attach to the caller's tmp directive (auto-creating it on first use). `list_directives_for_owner` filters out `is_tmp=true` so the scratchpad directive doesn't pollute the contract list — surfaced via the sidebar's `tmp/` folder instead. ## 30-day expiry on tmp tasks New `phase_tmp_expiry` in the directive reconciler. Throttled to once per hour: enumerates every tmp directive, calls `delete_expired_tmp_tasks`, logs the count. The actual delete is `WHERE created_at < NOW() - INTERVAL '30 days'` and is fast on the existing index. Subtasks die via FK cascade. ## Phase 5 — contracts removed ### Frontend Deleted entire `/contracts` surface: * routes: `contracts.tsx`, `contract-file.tsx` * components/contracts: ContractList, ContractDetail, ContractCliInput, ContractContextMenu, CommandModePanel, PhaseBadge, PhaseHint, PhaseDeliverablesPanel, PhaseProgressBar, QuickActionButtons, RepositoryPanel, TaskDerivationPreview * (Kept `PhaseConfirmationModal` — used outside the contracts surface by `TaskOutput` and `PhaseConfirmationNotification`.) * Routes deregistered from `main.tsx`; nav entry removed from `NavStrip`. ### Backend handlers Deleted: `contracts.rs` (2.4k LOC), `contract_chat.rs` (3.2k LOC), `contract_daemon.rs` (~940 LOC), `contract_discuss.rs` (~590 LOC), `transcript_analysis.rs` (~690 LOC). All `/api/v1/contracts/*` routes deregistered. OpenAPI entries dropped. Module declarations removed from `server/handlers/mod.rs`. ### CLI Removed `makima contract` and `makima supervisor` subcommands. Deleted `daemon/cli/contract.rs` and `daemon/cli/supervisor.rs`. Bin dispatch trimmed (~377 LOC). ### Orchestrator Removed the contract-spawn path from `phase_execution` (`spawn_step_contract` and its caller). `directive_steps.contract_type` now logs a warning and falls through to standalone-task spawn. Column itself stays — old data still reads, just no longer triggers a contract+supervisor spawn. ### TUI `Action::PerformCreateContract` is now a no-op that surfaces a status message: "Contracts have been removed. Use directives instead." The TUI form is dead code pending a wider refresh. ## Out of scope (deliberately left) * Contracts DB tables (`contracts`, `contract_repositories`, `contract_chat_history`, `contract_events`, `contract_templates`) are retained for historical data + because some peripheral code still joins to them in TaskSummary queries. * `mesh_supervisor` handlers are retained — they aren't only used by contracts (some mesh-level supervisor behaviour persists), and the cross-cutting cleanup is bigger than this PR. * `directive_steps.contract_type` column itself isn't dropped; just no longer functional. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server/handlers/transcript_analysis.rs')
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs690
1 files changed, 0 insertions, 690 deletions
diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs
deleted file mode 100644
index 9261c0c..0000000
--- a/makima/src/server/handlers/transcript_analysis.rs
+++ /dev/null
@@ -1,690 +0,0 @@
-//! 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,
- 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<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,
- 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<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,
- 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<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])
- }
-}