diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 23:56:51 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 23:56:51 +0100 |
| commit | e11759447b1ac00becfb1e979e488f7f9c9cf478 (patch) | |
| tree | f8a58368de3f6dda3f2f5c1af34e869a0e714205 /makima/src/server/handlers/transcript_analysis.rs | |
| parent | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (diff) | |
| download | soryu-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.rs | 690 |
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]) - } -} |
